Skip to main content

max / goingson

11.1 KB · 298 lines History Blame Raw
1 //! Natural-language date parser for free-text date fields.
2 //!
3 //! Single source of truth for the formats the UI accepts in date inputs
4 //! ("tomorrow", "friday 3pm", "next week", "in 3 days", "dec 25",
5 //! "2026-12-25", "2026-12-25 3pm", ISO datetime). The frontend reaches this
6 //! through the `parse_natural_date` command instead of maintaining a parallel
7 //! JavaScript parser ("Rust does the heavy lifting").
8 //!
9 //! All arithmetic is wall-clock (local) time: the caller passes the current
10 //! local time and receives a local `NaiveDateTime`, matching the previous JS
11 //! behaviour, which built `Date` objects in the browser's local zone and
12 //! emitted a local `YYYY-MM-DDTHH:MM` string.
13
14 use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
15
16 use crate::constants::{DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE};
17
18 /// Weekday names indexed Sunday=0..Saturday=6 (matches JS `Date.getDay`).
19 const WEEKDAYS: [&str; 7] = [
20 "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
21 ];
22 const MONTHS: [&str; 12] = [
23 "january", "february", "march", "april", "may", "june", "july", "august",
24 "september", "october", "november", "december",
25 ];
26 const MONTH_ABBRS: [&str; 12] = [
27 "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
28 ];
29
30 /// Parse a natural-language date string relative to `now` (local wall clock).
31 ///
32 /// Returns the resolved local `NaiveDateTime`, or `None` if unrecognized.
33 pub fn parse_natural_date(input: &str, now: NaiveDateTime) -> Option<NaiveDateTime> {
34 let trimmed = input.trim();
35 if trimmed.is_empty() {
36 return None;
37 }
38
39 // Already an ISO-ish datetime (YYYY-MM-DDTHH:MM or with a space / seconds).
40 if let Some(dt) = parse_iso_datetime(trimmed) {
41 return Some(dt);
42 }
43
44 let lower = trimmed.to_lowercase();
45 let default_time = NaiveTime::from_hms_opt(DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, 0)?;
46 let today = now.date();
47 let tokens: Vec<&str> = lower.split_whitespace().collect();
48
49 // Plain YYYY-MM-DD with an optional trailing time ("2026-12-25", "2026-12-25 3pm").
50 if let Ok(date) = NaiveDate::parse_from_str(tokens[0], "%Y-%m-%d") {
51 let time = parse_time_tokens(&tokens[1..], default_time)?;
52 return Some(date.and_time(time));
53 }
54
55 // Relative keywords.
56 match lower.as_str() {
57 "today" => return Some(today.and_time(default_time)),
58 "tomorrow" => return Some((today + Duration::days(1)).and_time(default_time)),
59 "yesterday" => return Some((today - Duration::days(1)).and_time(default_time)),
60 "next week" => {
61 // Next Monday at the default time.
62 let day = today.weekday().num_days_from_sunday() as i64;
63 let mut days_until_mon = (8 - day) % 7;
64 if days_until_mon == 0 {
65 days_until_mon = 7;
66 }
67 return Some((today + Duration::days(days_until_mon)).and_time(default_time));
68 }
69 _ => {}
70 }
71
72 // "in N days".
73 if tokens.len() == 3 && tokens[0] == "in" && (tokens[2] == "day" || tokens[2] == "days") {
74 if let Ok(n) = tokens[1].parse::<i64>()
75 && n >= 0
76 {
77 return Some((today + Duration::days(n)).and_time(default_time));
78 }
79 return None;
80 }
81
82 // Weekday names ("friday", "next friday", "friday 3pm").
83 if let Some(dt) = parse_weekday(&tokens, now, default_time) {
84 return Some(dt);
85 }
86
87 // Month + day ("dec 25", "december 25", "jan 5 3pm").
88 parse_month_day(&tokens, now, default_time)
89 }
90
91 /// Parse an explicit ISO-ish datetime in local wall-clock terms.
92 fn parse_iso_datetime(s: &str) -> Option<NaiveDateTime> {
93 const FORMATS: [&str; 4] = [
94 "%Y-%m-%dT%H:%M:%S",
95 "%Y-%m-%dT%H:%M",
96 "%Y-%m-%d %H:%M:%S",
97 "%Y-%m-%d %H:%M",
98 ];
99 FORMATS
100 .iter()
101 .find_map(|fmt| NaiveDateTime::parse_from_str(s, fmt).ok())
102 }
103
104 /// Resolve the next occurrence of a weekday, with optional `next` prefix and time.
105 fn parse_weekday(tokens: &[&str], now: NaiveDateTime, default_time: NaiveTime) -> Option<NaiveDateTime> {
106 let is_next = tokens.first() == Some(&"next");
107 let name_idx = if is_next { 1 } else { 0 };
108 let target = WEEKDAYS.iter().position(|d| Some(d) == tokens.get(name_idx))? as i64;
109
110 let today = now.date();
111 let current = today.weekday().num_days_from_sunday() as i64;
112 // Mirrors the previous JS offset logic exactly, including how "next" skips
113 // an extra week.
114 let mut diff = target - current;
115 if diff <= 0 || is_next {
116 diff += 7;
117 }
118 if is_next && diff <= 7 {
119 diff += 7;
120 }
121
122 let time = parse_time_tokens(&tokens[name_idx + 1..], default_time)?;
123 Some((today + Duration::days(diff)).and_time(time))
124 }
125
126 /// Resolve a "month day" reference, rolling to next year if already past.
127 fn parse_month_day(tokens: &[&str], now: NaiveDateTime, default_time: NaiveTime) -> Option<NaiveDateTime> {
128 if tokens.len() < 2 {
129 return None;
130 }
131 let month_idx = MONTH_ABBRS
132 .iter()
133 .position(|m| m == &tokens[0])
134 .or_else(|| MONTHS.iter().position(|m| m == &tokens[0]))?;
135 let day: u32 = tokens[1].parse().ok()?;
136
137 let year = now.year();
138 let mut date = NaiveDate::from_ymd_opt(year, month_idx as u32 + 1, day)?;
139 // Compare at midnight (as the JS did) before applying the time-of-day.
140 if date.and_hms_opt(0, 0, 0)? < now {
141 date = NaiveDate::from_ymd_opt(year + 1, month_idx as u32 + 1, day)?;
142 }
143
144 let time = parse_time_tokens(&tokens[2..], default_time)?;
145 Some(date.and_time(time))
146 }
147
148 /// Parse an optional trailing time ("3pm", "3:30pm", "15:30", "3 pm").
149 ///
150 /// Returns `default` when no tokens remain, or `None` when the trailing tokens
151 /// are present but not a valid time (so the whole input is rejected, as the old
152 /// anchored regexes did).
153 fn parse_time_tokens(tokens: &[&str], default: NaiveTime) -> Option<NaiveTime> {
154 if tokens.is_empty() {
155 return Some(default);
156 }
157 parse_clock(&tokens.concat())
158 }
159
160 /// Parse a clock string with an optional am/pm suffix and optional minutes.
161 fn parse_clock(raw: &str) -> Option<NaiveTime> {
162 let mut s = raw;
163 let mut ampm: Option<bool> = None; // Some(true) = pm, Some(false) = am
164 if let Some(stripped) = s.strip_suffix("pm") {
165 ampm = Some(true);
166 s = stripped;
167 } else if let Some(stripped) = s.strip_suffix("am") {
168 ampm = Some(false);
169 s = stripped;
170 }
171 let s = s.trim();
172
173 let (h_str, m_str) = match s.split_once(':') {
174 Some((h, m)) => (h, Some(m)),
175 None => (s, None),
176 };
177 let mut h: u32 = h_str.trim().parse().ok()?;
178 let m: u32 = match m_str {
179 Some(m) => m.trim().parse().ok()?,
180 None => 0,
181 };
182
183 match ampm {
184 Some(true) if h < 12 => h += 12,
185 Some(false) if h == 12 => h = 0,
186 _ => {}
187 }
188 if h > 23 || m > 59 {
189 return None;
190 }
191 NaiveTime::from_hms_opt(h, m, 0)
192 }
193
194 #[cfg(test)]
195 mod tests {
196 use super::*;
197
198 /// Reference "now": Wednesday 2026-06-17 at 14:30 local.
199 fn now() -> NaiveDateTime {
200 NaiveDate::from_ymd_opt(2026, 6, 17)
201 .unwrap()
202 .and_hms_opt(14, 30, 0)
203 .unwrap()
204 }
205
206 fn parse(s: &str) -> Option<NaiveDateTime> {
207 parse_natural_date(s, now())
208 }
209
210 #[test]
211 fn relative_keywords() {
212 assert_eq!(parse("today"), Some(NaiveDate::from_ymd_opt(2026, 6, 17).unwrap().and_hms_opt(9, 0, 0).unwrap()));
213 assert_eq!(parse("tomorrow"), Some(NaiveDate::from_ymd_opt(2026, 6, 18).unwrap().and_hms_opt(9, 0, 0).unwrap()));
214 assert_eq!(parse("yesterday"), Some(NaiveDate::from_ymd_opt(2026, 6, 16).unwrap().and_hms_opt(9, 0, 0).unwrap()));
215 }
216
217 #[test]
218 fn blank_and_garbage_are_none() {
219 assert_eq!(parse(""), None);
220 assert_eq!(parse(" "), None);
221 assert_eq!(parse("someday maybe"), None);
222 assert_eq!(parse("friday at noon"), None); // "at noon" is not a valid time suffix
223 }
224
225 #[test]
226 fn iso_datetime_preserves_wall_clock() {
227 assert_eq!(
228 parse("2026-12-25T15:30"),
229 Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 30, 0).unwrap())
230 );
231 assert_eq!(
232 parse("2026-12-25 15:30"),
233 Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 30, 0).unwrap())
234 );
235 }
236
237 #[test]
238 fn iso_date_defaults_to_nine_am() {
239 assert_eq!(
240 parse("2026-12-25"),
241 Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(9, 0, 0).unwrap())
242 );
243 assert_eq!(
244 parse("2026-12-25 3pm"),
245 Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 0, 0).unwrap())
246 );
247 }
248
249 #[test]
250 fn in_n_days() {
251 assert_eq!(parse("in 3 days"), Some(NaiveDate::from_ymd_opt(2026, 6, 20).unwrap().and_hms_opt(9, 0, 0).unwrap()));
252 assert_eq!(parse("in 1 day"), Some(NaiveDate::from_ymd_opt(2026, 6, 18).unwrap().and_hms_opt(9, 0, 0).unwrap()));
253 }
254
255 #[test]
256 fn next_week_is_next_monday() {
257 // 2026-06-17 is a Wednesday; next Monday is 2026-06-22.
258 assert_eq!(parse("next week"), Some(NaiveDate::from_ymd_opt(2026, 6, 22).unwrap().and_hms_opt(9, 0, 0).unwrap()));
259 }
260
261 #[test]
262 fn weekday_finds_next_occurrence() {
263 // From Wednesday, "friday" is 2026-06-19.
264 assert_eq!(parse("friday"), Some(NaiveDate::from_ymd_opt(2026, 6, 19).unwrap().and_hms_opt(9, 0, 0).unwrap()));
265 // Same weekday goes to next week (Wednesday -> 2026-06-24).
266 assert_eq!(parse("wednesday"), Some(NaiveDate::from_ymd_opt(2026, 6, 24).unwrap().and_hms_opt(9, 0, 0).unwrap()));
267 // "friday 3pm" applies the time.
268 assert_eq!(parse("friday 3pm"), Some(NaiveDate::from_ymd_opt(2026, 6, 19).unwrap().and_hms_opt(15, 0, 0).unwrap()));
269 }
270
271 #[test]
272 fn next_weekday_skips_an_extra_week() {
273 // "next friday" skips the coming Friday (matches the prior JS quirk):
274 // coming Friday is 06-19, so "next friday" -> 06-26.
275 assert_eq!(parse("next friday"), Some(NaiveDate::from_ymd_opt(2026, 6, 26).unwrap().and_hms_opt(9, 0, 0).unwrap()));
276 }
277
278 #[test]
279 fn month_day_rolls_to_next_year_when_past() {
280 // June 17 is past at 14:30; "jan 5" rolls to 2027.
281 assert_eq!(parse("jan 5"), Some(NaiveDate::from_ymd_opt(2027, 1, 5).unwrap().and_hms_opt(9, 0, 0).unwrap()));
282 // A future month stays this year.
283 assert_eq!(parse("december 25"), Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(9, 0, 0).unwrap()));
284 assert_eq!(parse("dec 25 3pm"), Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 0, 0).unwrap()));
285 }
286
287 #[test]
288 fn clock_parsing_handles_am_pm_and_24h() {
289 assert_eq!(parse_clock("3pm"), NaiveTime::from_hms_opt(15, 0, 0));
290 assert_eq!(parse_clock("3:30pm"), NaiveTime::from_hms_opt(15, 30, 0));
291 assert_eq!(parse_clock("12am"), NaiveTime::from_hms_opt(0, 0, 0));
292 assert_eq!(parse_clock("12pm"), NaiveTime::from_hms_opt(12, 0, 0));
293 assert_eq!(parse_clock("15:30"), NaiveTime::from_hms_opt(15, 30, 0));
294 assert_eq!(parse_clock("25:00"), None);
295 assert_eq!(parse_clock("noon"), None);
296 }
297 }
298