1use time::format_description::FormatItem;
4use time::macros::format_description;
5use time::{Duration, OffsetDateTime, Weekday};
6
7const ISO_DATE_FMT: &[FormatItem<'static>] = format_description!("[year]-[month]-[day]");
9
10pub fn parse_date(input: &str, now: OffsetDateTime) -> Result<OffsetDateTime, String> {
19 let input = input.trim().to_lowercase();
20
21 match input.as_str() {
22 "today" => Ok(midnight(now)),
23 "yesterday" => Ok(midnight(now) - Duration::days(1)),
24 _ => {
25 if let Some(day_str) = input.strip_prefix("last ") {
27 let target = parse_weekday(day_str.trim())?;
28 return Ok(last_weekday(now, target));
29 }
30
31 let date = time::Date::parse(&input, ISO_DATE_FMT)
33 .map_err(|_| format!("unrecognized date format: '{input}'"))?;
34 Ok(date.midnight().assume_utc())
35 }
36 }
37}
38
39fn midnight(dt: OffsetDateTime) -> OffsetDateTime {
41 dt.date().midnight().assume_utc()
42}
43
44fn parse_weekday(s: &str) -> Result<Weekday, String> {
46 match s {
47 "monday" | "mon" => Ok(Weekday::Monday),
48 "tuesday" | "tue" | "tues" => Ok(Weekday::Tuesday),
49 "wednesday" | "wed" => Ok(Weekday::Wednesday),
50 "thursday" | "thu" | "thurs" => Ok(Weekday::Thursday),
51 "friday" | "fri" => Ok(Weekday::Friday),
52 "saturday" | "sat" => Ok(Weekday::Saturday),
53 "sunday" | "sun" => Ok(Weekday::Sunday),
54 _ => Err(format!("unknown day: '{s}'")),
55 }
56}
57
58fn last_weekday(now: OffsetDateTime, target: Weekday) -> OffsetDateTime {
62 let today = now.weekday();
63 let days_back = match (today.number_days_from_monday() as i64)
64 - (target.number_days_from_monday() as i64)
65 {
66 diff if diff > 0 => diff,
67 diff if diff <= 0 => diff + 7,
68 _ => unreachable!(),
69 };
70 midnight(now) - Duration::days(days_back)
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use time::macros::datetime;
77
78 const NOW: OffsetDateTime = datetime!(2026-03-11 14:30:00 UTC);
80
81 #[test]
82 fn parse_today() {
83 let result = parse_date("today", NOW).unwrap();
84 assert_eq!(result, datetime!(2026-03-11 0:00 UTC));
85 }
86
87 #[test]
88 fn parse_yesterday() {
89 let result = parse_date("yesterday", NOW).unwrap();
90 assert_eq!(result, datetime!(2026-03-10 0:00 UTC));
91 }
92
93 #[test]
94 fn parse_iso_date() {
95 let result = parse_date("2026-01-15", NOW).unwrap();
96 assert_eq!(result, datetime!(2026-01-15 0:00 UTC));
97 }
98
99 #[test]
100 fn parse_last_monday() {
101 let result = parse_date("last monday", NOW).unwrap();
103 assert_eq!(result, datetime!(2026-03-09 0:00 UTC));
104 }
105
106 #[test]
107 fn parse_last_friday() {
108 let result = parse_date("last friday", NOW).unwrap();
110 assert_eq!(result, datetime!(2026-03-06 0:00 UTC));
111 }
112
113 #[test]
114 fn parse_last_wednesday_goes_back_7() {
115 let result = parse_date("last wednesday", NOW).unwrap();
117 assert_eq!(result, datetime!(2026-03-04 0:00 UTC));
118 }
119
120 #[test]
121 fn parse_abbreviated_day() {
122 let result = parse_date("last mon", NOW).unwrap();
123 assert_eq!(result, datetime!(2026-03-09 0:00 UTC));
124 }
125
126 #[test]
127 fn parse_case_insensitive() {
128 let result = parse_date("Yesterday", NOW).unwrap();
129 assert_eq!(result, datetime!(2026-03-10 0:00 UTC));
130 }
131
132 #[test]
133 fn parse_with_whitespace() {
134 let result = parse_date(" today ", NOW).unwrap();
135 assert_eq!(result, datetime!(2026-03-11 0:00 UTC));
136 }
137
138 #[test]
139 fn parse_invalid_errors() {
140 assert!(parse_date("not-a-date", NOW).is_err());
141 }
142
143 #[test]
144 fn parse_unknown_day_errors() {
145 assert!(parse_date("last blorpday", NOW).is_err());
146 }
147}