Skip to main content

mps/
date_parse.rs

1use chrono::{Datelike, Duration, Local, NaiveDate, Weekday};
2use crate::error::MpsError;
3
4/// Parse a natural-language or YYYYMMDD date string into a NaiveDate.
5/// Supported: today, yesterday, YYYYMMDD, last <weekday>, <N> days ago,
6///            last week, monday/tuesday/.../sunday (means last occurrence).
7pub fn parse_date(input: &str) -> Result<NaiveDate, MpsError> {
8    let s = input.trim().to_lowercase();
9    let today = Local::now().date_naive();
10
11    match s.as_str() {
12        "today"     => return Ok(today),
13        "yesterday" => return Ok(today - Duration::days(1)),
14        "last week" => return Ok(today - Duration::days(7)),
15        _ => {}
16    }
17
18    // "N days ago"
19    if let Some(rest) = s.strip_suffix(" days ago") {
20        if let Ok(n) = rest.trim().parse::<i64>() {
21            return Ok(today - Duration::days(n));
22        }
23    }
24    if let Some(rest) = s.strip_suffix(" day ago") {
25        if rest.trim() == "1" || rest.trim() == "a" {
26            return Ok(today - Duration::days(1));
27        }
28    }
29
30    // "last <weekday>"
31    if let Some(rest) = s.strip_prefix("last ") {
32        if let Some(wd) = parse_weekday(rest.trim()) {
33            return Ok(last_weekday(today, wd));
34        }
35    }
36
37    // bare weekday name → last occurrence (not including today)
38    if let Some(wd) = parse_weekday(&s) {
39        return Ok(last_weekday(today, wd));
40    }
41
42    // YYYYMMDD
43    if s.len() == 8 && s.chars().all(|c| c.is_ascii_digit()) {
44        return NaiveDate::parse_from_str(&s, "%Y%m%d")
45            .map_err(|_| MpsError::DateParseError(input.to_string()));
46    }
47
48    // YYYY-MM-DD
49    if s.len() == 10 && s.chars().nth(4) == Some('-') {
50        return NaiveDate::parse_from_str(&s, "%Y-%m-%d")
51            .map_err(|_| MpsError::DateParseError(input.to_string()));
52    }
53
54    Err(MpsError::DateParseError(input.to_string()))
55}
56
57fn parse_weekday(s: &str) -> Option<Weekday> {
58    match s {
59        "monday"    | "mon" => Some(Weekday::Mon),
60        "tuesday"   | "tue" => Some(Weekday::Tue),
61        "wednesday" | "wed" => Some(Weekday::Wed),
62        "thursday"  | "thu" => Some(Weekday::Thu),
63        "friday"    | "fri" => Some(Weekday::Fri),
64        "saturday"  | "sat" => Some(Weekday::Sat),
65        "sunday"    | "sun" => Some(Weekday::Sun),
66        _ => None,
67    }
68}
69
70/// Most recent past occurrence of weekday (never today).
71fn last_weekday(today: NaiveDate, wd: Weekday) -> NaiveDate {
72    let mut d = today - Duration::days(1);
73    while d.weekday() != wd {
74        d -= Duration::days(1);
75    }
76    d
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn test_today() {
85        assert_eq!(parse_date("today").unwrap(), Local::now().date_naive());
86    }
87
88    #[test]
89    fn test_yesterday() {
90        let expected = Local::now().date_naive() - Duration::days(1);
91        assert_eq!(parse_date("yesterday").unwrap(), expected);
92    }
93
94    #[test]
95    fn test_yyyymmdd() {
96        assert_eq!(
97            parse_date("20260428").unwrap(),
98            NaiveDate::from_ymd_opt(2026, 4, 28).unwrap()
99        );
100    }
101
102    #[test]
103    fn test_yyyy_mm_dd() {
104        assert_eq!(
105            parse_date("2026-04-28").unwrap(),
106            NaiveDate::from_ymd_opt(2026, 4, 28).unwrap()
107        );
108    }
109
110    #[test]
111    fn test_n_days_ago() {
112        let expected = Local::now().date_naive() - Duration::days(3);
113        assert_eq!(parse_date("3 days ago").unwrap(), expected);
114    }
115
116    #[test]
117    fn test_last_week() {
118        let expected = Local::now().date_naive() - Duration::days(7);
119        assert_eq!(parse_date("last week").unwrap(), expected);
120    }
121
122    #[test]
123    fn test_invalid() {
124        assert!(parse_date("gibberish date string").is_err());
125    }
126}