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