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>` or `None` for DST gaps.
66fn 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
70/// Set a date to the beginning of its day (midnight), falling back to progressively later
71/// hours when midnight lands in a DST gap.
72fn 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  // Final fallback: interpret as UTC and convert to local
85  date.and_time(NaiveTime::MIN).and_utc().with_timezone(&Local)
86}
87
88/// Parse absolute date expressions: `YYYY-MM-DD`, `YYYY-MM-DD HH:MM`,
89/// `MM/DD/YY`, `MM/DD/YYYY`.
90fn parse_absolute(input: &str) -> Option<DateTime<Local>> {
91  // YYYY-MM-DD HH:MM
92  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  // YYYY-MM-DD
105  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  // MM/DD/YYYY
115  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  // MM/DD/YY
125  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  // MM/DD (short US date, no year — resolve to current year or most recent past)
136  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    // If the date is in the future, use last year
144    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
155/// Parse `N unit(s) ago` expressions. Supports shorthand (`30m ago`, `2h ago`)
156/// and long form (`3 days ago`, `one month ago`).
157fn 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
175/// Parse combined date + time expressions: `yesterday 3pm`, `monday 9:30am`,
176/// `last friday at noon`, `tomorrow 15:00`.
177fn parse_combined(input: &str) -> Option<DateTime<Local>> {
178  // Split on " at " first, then fall back to splitting on last space
179  let (date_part, time_part) = if let Some((d, t)) = input.split_once(" at ") {
180    (d.trim(), t.trim())
181  } else {
182    // Find the time portion at the end: look for a token that resolves as a time
183    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  // Try to resolve the date part
191  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
204/// Parse day-of-week expressions: `monday`, `last tuesday`, `next friday`.
205/// Bare weekday names default to the most recent past occurrence.
206fn 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
216/// Parse a word or digit string as an integer. Supports written-out numbers
217/// (`one` through `twelve`) and plain digits.
218fn 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
236/// Parse relative date expressions: `now`, `today`, `yesterday`, `tomorrow`,
237/// and offset expressions like `2 hours ago`, `30m ago`, `3 days ago`.
238fn 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
252/// Parse a bare duration shorthand (e.g. `24h`, `30m`, `1d2h`) as an offset
253/// into the past from now.
254fn parse_shorthand_duration(input: &str) -> Option<DateTime<Local>> {
255  let duration = parse_duration(input).ok()?;
256  Some(Local::now() - duration)
257}
258
259/// Parse a time-only expression into today's date with the given time.
260/// Supports `noon`, `midnight`, `3pm`, `3:30pm`, `15:00`.
261/// Bare times always resolve to today, matching the original Ruby behavior.
262fn 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
268/// Convert a weekday abbreviation to a `chrono::Weekday`.
269fn 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
282/// Parse a time string into a `NaiveTime`. Supports `noon`, `midnight`,
283/// `3pm`, `3:30pm`, `15:00`.
284fn 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  // 12-hour: 3pm, 3:30pm, 12:00am
292  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  // 24-hour: 15:00, 08:30
311  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
325/// Resolve a weekday relative to `now`. `last` looks back, `next` looks forward,
326/// `None`/`this` defaults to the most recent past occurrence.
327fn 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  match direction {
333    Some("next") => {
334      let d = target_num - current_num;
335      let diff = if d <= 0 { d + 7 } else { d };
336      now + Duration::days(diff)
337    }
338    Some("this") => {
339      // "this <weekday>" resolves to the current week's instance (Mon-Sun).
340      // Same day returns today; other days may be in the past or future.
341      let d = target_num - current_num;
342      if d >= 0 {
343        now + Duration::days(d)
344      } else {
345        now - Duration::days(-d)
346      }
347    }
348    _ => {
349      // "last" and bare weekday both resolve to the most recent past occurrence.
350      // Same day = 7 days ago.
351      let d = current_num - target_num;
352      let diff = if d <= 0 { d + 7 } else { d };
353      now - Duration::days(diff)
354    }
355  }
356}
357
358#[cfg(test)]
359mod test {
360  use super::*;
361
362  mod beginning_of_day {
363    use super::*;
364
365    #[test]
366    fn it_does_not_panic_on_dst_gap_dates() {
367      // 2024-03-10 is US spring-forward; 2024-10-06 is Brazil spring-forward.
368      // At least one of these may have a midnight DST gap depending on the
369      // test machine's timezone. The function must not panic for any date.
370      let dates = [
371        NaiveDate::from_ymd_opt(2024, 3, 10).unwrap(),
372        NaiveDate::from_ymd_opt(2024, 10, 6).unwrap(),
373        NaiveDate::from_ymd_opt(2019, 11, 3).unwrap(),
374      ];
375      for date in &dates {
376        let result = beginning_of_day(*date);
377        assert_eq!(result.date_naive(), *date);
378      }
379    }
380
381    #[test]
382    fn it_returns_midnight_for_normal_dates() {
383      let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
384      let result = beginning_of_day(date);
385
386      assert_eq!(result.date_naive(), date);
387    }
388  }
389
390  mod chronify {
391    use pretty_assertions::assert_eq;
392
393    use super::*;
394
395    #[test]
396    fn it_parses_absolute_iso_date() {
397      let result = chronify("2024-03-15").unwrap();
398
399      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
400      assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
401    }
402
403    #[test]
404    fn it_parses_absolute_iso_datetime() {
405      let result = chronify("2024-03-15 14:30").unwrap();
406
407      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
408      assert_eq!(result.time(), NaiveTime::from_hms_opt(14, 30, 0).unwrap());
409    }
410
411    #[test]
412    fn it_parses_absolute_us_long_date() {
413      let result = chronify("03/15/2024").unwrap();
414
415      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
416    }
417
418    #[test]
419    fn it_parses_absolute_us_short_date() {
420      let result = chronify("03/15/24").unwrap();
421
422      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
423    }
424
425    #[test]
426    fn it_parses_bare_abbreviated_day_name() {
427      let result = chronify("fri").unwrap();
428
429      assert_eq!(result.weekday(), Weekday::Fri);
430      assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
431    }
432
433    #[test]
434    fn it_parses_bare_full_day_name() {
435      let result = chronify("friday").unwrap();
436
437      assert_eq!(result.weekday(), Weekday::Fri);
438      assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
439    }
440
441    #[test]
442    fn it_parses_combined_day_of_week_with_time() {
443      let result = chronify("yesterday 3pm").unwrap();
444      let expected_date = (Local::now() - Duration::days(1)).date_naive();
445
446      assert_eq!(result.date_naive(), expected_date);
447      assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
448    }
449
450    #[test]
451    fn it_parses_combined_with_24h_time() {
452      let result = chronify("tomorrow 15:00").unwrap();
453      let expected_date = (Local::now() + Duration::days(1)).date_naive();
454
455      assert_eq!(result.date_naive(), expected_date);
456      assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
457    }
458
459    #[test]
460    fn it_parses_combined_with_at_keyword() {
461      let result = chronify("yesterday at noon").unwrap();
462      let expected_date = (Local::now() - Duration::days(1)).date_naive();
463
464      assert_eq!(result.date_naive(), expected_date);
465      assert_eq!(result.time(), NaiveTime::from_hms_opt(12, 0, 0).unwrap());
466    }
467
468    #[test]
469    fn it_parses_now() {
470      let before = Local::now();
471      let result = chronify("now").unwrap();
472      let after = Local::now();
473
474      assert!(result >= before && result <= after);
475    }
476
477    #[test]
478    fn it_parses_shorthand_duration_hours() {
479      let before = Local::now();
480      let result = chronify("24h").unwrap();
481      let after = Local::now();
482
483      let expected_before = before - Duration::hours(24);
484      let expected_after = after - Duration::hours(24);
485
486      assert!(result >= expected_before && result <= expected_after);
487    }
488
489    #[test]
490    fn it_parses_shorthand_duration_minutes() {
491      let before = Local::now();
492      let result = chronify("30m").unwrap();
493      let after = Local::now();
494
495      let expected_before = before - Duration::minutes(30);
496      let expected_after = after - Duration::minutes(30);
497
498      assert!(result >= expected_before && result <= expected_after);
499    }
500
501    #[test]
502    fn it_parses_shorthand_duration_multi_unit() {
503      let before = Local::now();
504      let result = chronify("1d2h").unwrap();
505      let after = Local::now();
506
507      let expected_before = before - Duration::hours(26);
508      let expected_after = after - Duration::hours(26);
509
510      assert!(result >= expected_before && result <= expected_after);
511    }
512
513    #[test]
514    fn it_parses_today() {
515      let result = chronify("today").unwrap();
516
517      assert_eq!(result.date_naive(), Local::now().date_naive());
518      assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
519    }
520
521    #[test]
522    fn it_parses_tomorrow() {
523      let result = chronify("tomorrow").unwrap();
524      let expected = (Local::now() + Duration::days(1)).date_naive();
525
526      assert_eq!(result.date_naive(), expected);
527    }
528
529    #[test]
530    fn it_parses_yesterday() {
531      let result = chronify("yesterday").unwrap();
532      let expected = (Local::now() - Duration::days(1)).date_naive();
533
534      assert_eq!(result.date_naive(), expected);
535    }
536
537    #[test]
538    fn it_rejects_empty_input() {
539      let err = chronify("").unwrap_err();
540
541      assert!(matches!(err, Error::InvalidTimeExpression(_)));
542    }
543
544    #[test]
545    fn it_rejects_invalid_input() {
546      let err = chronify("not a date").unwrap_err();
547
548      assert!(matches!(err, Error::InvalidTimeExpression(_)));
549    }
550
551    #[test]
552    fn it_trims_whitespace() {
553      let result = chronify("  today  ").unwrap();
554
555      assert_eq!(result.date_naive(), Local::now().date_naive());
556    }
557  }
558
559  mod parse_ago {
560    use pretty_assertions::assert_eq;
561
562    use super::*;
563
564    #[test]
565    fn it_parses_days_ago() {
566      let now = Local::now();
567      let result = parse_ago("3 days ago", now).unwrap();
568
569      assert_eq!(result.date_naive(), (now - Duration::days(3)).date_naive());
570    }
571
572    #[test]
573    fn it_parses_hours_ago() {
574      let now = Local::now();
575      let result = parse_ago("2 hours ago", now).unwrap();
576      let expected = now - Duration::hours(2);
577
578      assert!((result - expected).num_seconds().abs() < 1);
579    }
580
581    #[test]
582    fn it_parses_minutes_shorthand() {
583      let now = Local::now();
584      let result = parse_ago("30m ago", now).unwrap();
585      let expected = now - Duration::minutes(30);
586
587      assert!((result - expected).num_seconds().abs() < 1);
588    }
589
590    #[test]
591    fn it_parses_weeks_ago() {
592      let now = Local::now();
593      let result = parse_ago("2 weeks ago", now).unwrap();
594
595      assert_eq!(result.date_naive(), (now - Duration::weeks(2)).date_naive());
596    }
597
598    #[test]
599    fn it_parses_written_numbers() {
600      let now = Local::now();
601      let result = parse_ago("one hour ago", now).unwrap();
602      let expected = now - Duration::hours(1);
603
604      assert!((result - expected).num_seconds().abs() < 1);
605    }
606
607    #[test]
608    fn it_returns_none_for_invalid_input() {
609      let now = Local::now();
610
611      assert!(parse_ago("not valid", now).is_none());
612    }
613  }
614
615  mod parse_day_of_week {
616    use pretty_assertions::assert_eq;
617
618    use super::*;
619
620    #[test]
621    fn it_parses_abbreviations() {
622      for abbr in &["mon", "tue", "wed", "thu", "fri", "sat", "sun"] {
623        let result = parse_day_of_week(abbr);
624        assert!(result.is_some(), "parse_day_of_week should parse abbreviation: {abbr}");
625      }
626    }
627
628    #[test]
629    fn it_parses_alternate_abbreviations() {
630      for abbr in &["tues", "weds", "thur", "thurs"] {
631        let result = parse_day_of_week(abbr);
632        assert!(
633          result.is_some(),
634          "parse_day_of_week should parse alternate abbreviation: {abbr}"
635        );
636      }
637    }
638
639    #[test]
640    fn it_parses_full_day_names() {
641      for name in &[
642        "monday",
643        "tuesday",
644        "wednesday",
645        "thursday",
646        "friday",
647        "saturday",
648        "sunday",
649      ] {
650        let result = parse_day_of_week(name);
651        assert!(result.is_some(), "parse_day_of_week should parse full name: {name}");
652      }
653    }
654
655    #[test]
656    fn it_parses_full_names_with_direction() {
657      let result = parse_day_of_week("last friday");
658      assert!(result.is_some(), "parse_day_of_week should parse 'last friday'");
659
660      let result = parse_day_of_week("next monday");
661      assert!(result.is_some(), "parse_day_of_week should parse 'next monday'");
662    }
663
664    #[test]
665    fn it_resolves_bare_day_to_most_recent_past() {
666      let result = parse_day_of_week("friday").unwrap();
667      let now = Local::now();
668
669      // Result should be in the past (or at most today at midnight)
670      assert!(result <= now, "bare day name should resolve to a past date");
671
672      // Result should be within the last 7 days (use 8-day window to account for
673      // same-weekday resolving to 7 days ago at midnight)
674      let cutoff = now - Duration::days(8);
675      assert!(
676        result > cutoff,
677        "bare day name should resolve to within the last 7 days"
678      );
679
680      // Result should be a Friday
681      assert_eq!(result.weekday(), Weekday::Fri);
682    }
683  }
684
685  mod parse_number {
686    use pretty_assertions::assert_eq;
687
688    use super::*;
689
690    #[test]
691    fn it_parses_a_as_one() {
692      assert_eq!(parse_number("a"), Some(1));
693      assert_eq!(parse_number("an"), Some(1));
694    }
695
696    #[test]
697    fn it_parses_digits() {
698      assert_eq!(parse_number("42"), Some(42));
699    }
700
701    #[test]
702    fn it_parses_written_numbers() {
703      assert_eq!(parse_number("one"), Some(1));
704      assert_eq!(parse_number("six"), Some(6));
705      assert_eq!(parse_number("twelve"), Some(12));
706    }
707
708    #[test]
709    fn it_returns_none_for_invalid_input() {
710      assert!(parse_number("foo").is_none());
711    }
712  }
713
714  mod parse_shorthand_duration {
715    use super::*;
716
717    #[test]
718    fn it_parses_hours() {
719      let before = Local::now();
720      let result = parse_shorthand_duration("48h").unwrap();
721      let after = Local::now();
722
723      let expected_before = before - Duration::hours(48);
724      let expected_after = after - Duration::hours(48);
725
726      assert!(result >= expected_before && result <= expected_after);
727    }
728
729    #[test]
730    fn it_parses_minutes() {
731      let before = Local::now();
732      let result = parse_shorthand_duration("15m").unwrap();
733      let after = Local::now();
734
735      let expected_before = before - Duration::minutes(15);
736      let expected_after = after - Duration::minutes(15);
737
738      assert!(result >= expected_before && result <= expected_after);
739    }
740
741    #[test]
742    fn it_returns_none_for_invalid_input() {
743      assert!(parse_shorthand_duration("not valid").is_none());
744    }
745  }
746
747  mod parse_time_only {
748    use pretty_assertions::assert_eq;
749
750    use super::*;
751
752    #[test]
753    fn it_resolves_bare_time_to_today() {
754      let result = parse_time_only("3pm").unwrap();
755
756      assert_eq!(result.date_naive(), Local::now().date_naive());
757      assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
758    }
759
760    #[test]
761    fn it_resolves_future_time_to_today() {
762      let result = parse_time_only("11:59pm").unwrap();
763
764      assert_eq!(result.date_naive(), Local::now().date_naive());
765      assert_eq!(result.time(), NaiveTime::from_hms_opt(23, 59, 0).unwrap());
766    }
767  }
768
769  mod parse_weekday {
770    use pretty_assertions::assert_eq;
771
772    use super::*;
773
774    #[test]
775    fn it_parses_abbreviations() {
776      assert_eq!(parse_weekday("mon"), Some(Weekday::Mon));
777      assert_eq!(parse_weekday("tue"), Some(Weekday::Tue));
778      assert_eq!(parse_weekday("wed"), Some(Weekday::Wed));
779      assert_eq!(parse_weekday("thu"), Some(Weekday::Thu));
780      assert_eq!(parse_weekday("fri"), Some(Weekday::Fri));
781      assert_eq!(parse_weekday("sat"), Some(Weekday::Sat));
782      assert_eq!(parse_weekday("sun"), Some(Weekday::Sun));
783    }
784
785    #[test]
786    fn it_returns_none_for_invalid_input() {
787      assert!(parse_weekday("xyz").is_none());
788    }
789  }
790
791  mod resolve_time_expression {
792    use pretty_assertions::assert_eq;
793
794    use super::*;
795
796    #[test]
797    fn it_parses_12_hour_with_minutes() {
798      let result = resolve_time_expression("3:30pm").unwrap();
799
800      assert_eq!(result, NaiveTime::from_hms_opt(15, 30, 0).unwrap());
801    }
802
803    #[test]
804    fn it_parses_12_hour_without_minutes() {
805      let result = resolve_time_expression("3pm").unwrap();
806
807      assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
808    }
809
810    #[test]
811    fn it_parses_12am_as_midnight() {
812      let result = resolve_time_expression("12am").unwrap();
813
814      assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
815    }
816
817    #[test]
818    fn it_parses_12pm_as_noon() {
819      let result = resolve_time_expression("12pm").unwrap();
820
821      assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
822    }
823
824    #[test]
825    fn it_parses_24_hour() {
826      let result = resolve_time_expression("15:00").unwrap();
827
828      assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
829    }
830
831    #[test]
832    fn it_parses_midnight() {
833      let result = resolve_time_expression("midnight").unwrap();
834
835      assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
836    }
837
838    #[test]
839    fn it_parses_noon() {
840      let result = resolve_time_expression("noon").unwrap();
841
842      assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
843    }
844
845    #[test]
846    fn it_rejects_invalid_hour() {
847      assert!(resolve_time_expression("25:00").is_none());
848    }
849
850    #[test]
851    fn it_returns_none_for_invalid_input() {
852      assert!(resolve_time_expression("not a time").is_none());
853    }
854  }
855
856  mod resolve_weekday {
857    use pretty_assertions::assert_eq;
858
859    use super::*;
860
861    #[test]
862    fn it_defaults_bare_weekday_to_past() {
863      let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); // Tuesday
864      let result = resolve_weekday(now, Weekday::Mon, None);
865
866      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
867    }
868
869    #[test]
870    fn it_resolves_last_to_past() {
871      let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); // Tuesday
872      let result = resolve_weekday(now, Weekday::Mon, Some("last"));
873
874      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
875    }
876
877    #[test]
878    fn it_resolves_next_to_future() {
879      let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); // Tuesday
880      let result = resolve_weekday(now, Weekday::Fri, Some("next"));
881
882      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
883    }
884
885    #[test]
886    fn it_resolves_same_day_last_to_one_week_ago() {
887      let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); // Tuesday
888      let result = resolve_weekday(now, Weekday::Tue, Some("last"));
889
890      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
891    }
892
893    #[test]
894    fn it_resolves_same_day_next_to_one_week_ahead() {
895      let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); // Tuesday
896      let result = resolve_weekday(now, Weekday::Tue, Some("next"));
897
898      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 24).unwrap());
899    }
900
901    #[test]
902    fn it_resolves_this_same_day_to_today() {
903      let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); // Tuesday
904      let result = resolve_weekday(now, Weekday::Tue, Some("this"));
905
906      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 17).unwrap());
907    }
908
909    #[test]
910    fn it_resolves_this_past_day_to_current_week() {
911      let now = Local.with_ymd_and_hms(2026, 3, 19, 12, 0, 0).unwrap(); // Thursday
912      let result = resolve_weekday(now, Weekday::Mon, Some("this"));
913
914      // "this monday" on a Thursday resolves to the Monday of the current week
915      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
916    }
917
918    #[test]
919    fn it_resolves_this_future_day_to_current_week() {
920      let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); // Tuesday
921      let result = resolve_weekday(now, Weekday::Fri, Some("this"));
922
923      // "this friday" on a Tuesday resolves to the Friday of the current week
924      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
925    }
926
927    #[test]
928    fn it_resolves_bare_same_day_to_one_week_ago() {
929      let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); // Tuesday
930      let result = resolve_weekday(now, Weekday::Tue, None);
931
932      // Bare "tuesday" on a Tuesday resolves to 7 days ago (past-biased)
933      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
934    }
935  }
936}