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/// Subtract calendar months from a date, clamping the day to the last day of the target month
71/// (e.g. March 31 - 1 month = February 28/29).
72fn 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  // Clamp day to last valid day of target month
79  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
90/// Return the last day of a given month.
91fn 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
99/// Set a date to the beginning of its day (midnight), falling back to progressively later
100/// hours when midnight lands in a DST gap.
101fn 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  // Final fallback: interpret as UTC and convert to local
114  date.and_time(NaiveTime::MIN).and_utc().with_timezone(&Local)
115}
116
117/// Parse absolute date expressions: `YYYY-MM-DD`, `YYYY-MM-DD HH:MM`,
118/// `MM/DD/YY`, `MM/DD/YYYY`.
119fn parse_absolute(input: &str) -> Option<DateTime<Local>> {
120  // YYYY-MM-DD HH:MM
121  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  // YYYY-MM-DD
134  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  // MM/DD/YYYY
144  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  // MM/DD/YY
154  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  // MM/DD (short US date, no year — resolve to most recent valid past date)
165  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    // Search backward from current year to find a valid date (handles Feb 29 in non-leap years)
171    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
185/// Parse `N unit(s) ago` expressions. Supports shorthand (`30m ago`, `2h ago`)
186/// and long form (`3 days ago`, `one month ago`).
187fn 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
211/// Parse combined date + time expressions: `yesterday 3pm`, `monday 9:30am`,
212/// `last friday at noon`, `tomorrow 15:00`.
213fn parse_combined(input: &str) -> Option<DateTime<Local>> {
214  // Split on " at " first, then fall back to splitting on last space
215  let (date_part, time_part) = if let Some((d, t)) = input.split_once(" at ") {
216    (d.trim(), t.trim())
217  } else {
218    // Find the time portion at the end: look for a token that resolves as a time
219    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  // Try to resolve the date part
227  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
238/// Parse day-of-week expressions: `monday`, `last tuesday`, `next friday`.
239/// Bare weekday names default to the most recent past occurrence.
240fn 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
250/// Parse a word or digit string as an integer. Supports written-out numbers
251/// (`one` through `twelve`) and plain digits.
252fn 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
279/// Parse relative date expressions: `now`, `today`, `yesterday`, `tomorrow`,
280/// and offset expressions like `2 hours ago`, `30m ago`, `3 days ago`.
281fn 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
295/// Parse a bare duration shorthand (e.g. `24h`, `30m`, `1d2h`) as an offset
296/// into the past from now.
297fn parse_shorthand_duration(input: &str) -> Option<DateTime<Local>> {
298  let duration = parse_duration(input).ok()?;
299  Some(Local::now() - duration)
300}
301
302/// Parse a time-only expression into today's date with the given time.
303/// Supports `noon`, `midnight`, `3pm`, `3:30pm`, `15:00`.
304/// Bare times always resolve to today, matching the original Ruby behavior.
305fn 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
311/// Convert a weekday abbreviation to a `chrono::Weekday`.
312fn 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
325/// Parse a time string into a `NaiveTime`. Supports `noon`, `midnight`,
326/// `3pm`, `3:30pm`, `15:00`.
327fn 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  // 12-hour: 3pm, 3:30pm, 12:00am
335  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  // 24-hour: 15:00, 08:30
354  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
368/// Resolve a weekday relative to `now`. `last` looks back, `next` looks forward,
369/// `None`/`this` defaults to the most recent past occurrence.
370fn 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      // "this <weekday>" resolves to the current week's instance (Mon-Sun).
383      // Same day returns today; other days may be in the past or future.
384      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      // "last" and bare weekday both resolve to the most recent past occurrence.
393      // Same day = 7 days ago.
394      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      // 2024-03-10 is US spring-forward; 2024-10-06 is Brazil spring-forward.
411      // At least one of these may have a midnight DST gap depending on the
412      // test machine's timezone. The function must not panic for any date.
413      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      // March 31 - 1 month = February 29 (2024 is a leap year)
679      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      // March 31 - 1 month = February 28 (2025 is not a leap year)
689      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      // Result should be in the past (or at most today at midnight)
749      assert!(result <= now, "bare day name should resolve to a past date");
750
751      // Result should be within the last 7 days (use 8-day window to account for
752      // same-weekday resolving to 7 days ago at midnight)
753      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      // Result should be a Friday
760      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(); // Tuesday
960      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(); // Tuesday
968      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(); // Tuesday
976      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(); // Tuesday
984      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(); // Tuesday
992      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(); // Tuesday
1000      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(); // Thursday
1008      let result = resolve_weekday(now, Weekday::Mon, Some("this"));
1009
1010      // "this monday" on a Thursday resolves to the Monday of the current week
1011      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(); // Tuesday
1017      let result = resolve_weekday(now, Weekday::Fri, Some("this"));
1018
1019      // "this friday" on a Tuesday resolves to the Friday of the current week
1020      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(); // Tuesday
1026      let result = resolve_weekday(now, Weekday::Tue, None);
1027
1028      // Bare "tuesday" on a Tuesday resolves to 7 days ago (past-biased)
1029      assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
1030    }
1031  }
1032}