//! Natural-language date parser for free-text date fields. //! //! Single source of truth for the formats the UI accepts in date inputs //! ("tomorrow", "friday 3pm", "next week", "in 3 days", "dec 25", //! "2026-12-25", "2026-12-25 3pm", ISO datetime). The frontend reaches this //! through the `parse_natural_date` command instead of maintaining a parallel //! JavaScript parser ("Rust does the heavy lifting"). //! //! All arithmetic is wall-clock (local) time: the caller passes the current //! local time and receives a local `NaiveDateTime`, matching the previous JS //! behaviour, which built `Date` objects in the browser's local zone and //! emitted a local `YYYY-MM-DDTHH:MM` string. use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::constants::{DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE}; /// Weekday names indexed Sunday=0..Saturday=6 (matches JS `Date.getDay`). const WEEKDAYS: [&str; 7] = [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", ]; const MONTHS: [&str; 12] = [ "january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december", ]; const MONTH_ABBRS: [&str; 12] = [ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", ]; /// Parse a natural-language date string relative to `now` (local wall clock). /// /// Returns the resolved local `NaiveDateTime`, or `None` if unrecognized. pub fn parse_natural_date(input: &str, now: NaiveDateTime) -> Option { let trimmed = input.trim(); if trimmed.is_empty() { return None; } // Already an ISO-ish datetime (YYYY-MM-DDTHH:MM or with a space / seconds). if let Some(dt) = parse_iso_datetime(trimmed) { return Some(dt); } let lower = trimmed.to_lowercase(); let default_time = NaiveTime::from_hms_opt(DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, 0)?; let today = now.date(); let tokens: Vec<&str> = lower.split_whitespace().collect(); // Plain YYYY-MM-DD with an optional trailing time ("2026-12-25", "2026-12-25 3pm"). if let Ok(date) = NaiveDate::parse_from_str(tokens[0], "%Y-%m-%d") { let time = parse_time_tokens(&tokens[1..], default_time)?; return Some(date.and_time(time)); } // Relative keywords. match lower.as_str() { "today" => return Some(today.and_time(default_time)), "tomorrow" => return Some((today + Duration::days(1)).and_time(default_time)), "yesterday" => return Some((today - Duration::days(1)).and_time(default_time)), "next week" => { // Next Monday at the default time. let day = today.weekday().num_days_from_sunday() as i64; let mut days_until_mon = (8 - day) % 7; if days_until_mon == 0 { days_until_mon = 7; } return Some((today + Duration::days(days_until_mon)).and_time(default_time)); } _ => {} } // "in N days". if tokens.len() == 3 && tokens[0] == "in" && (tokens[2] == "day" || tokens[2] == "days") { if let Ok(n) = tokens[1].parse::() && n >= 0 { return Some((today + Duration::days(n)).and_time(default_time)); } return None; } // Weekday names ("friday", "next friday", "friday 3pm"). if let Some(dt) = parse_weekday(&tokens, now, default_time) { return Some(dt); } // Month + day ("dec 25", "december 25", "jan 5 3pm"). parse_month_day(&tokens, now, default_time) } /// Parse an explicit ISO-ish datetime in local wall-clock terms. fn parse_iso_datetime(s: &str) -> Option { const FORMATS: [&str; 4] = [ "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", ]; FORMATS .iter() .find_map(|fmt| NaiveDateTime::parse_from_str(s, fmt).ok()) } /// Resolve the next occurrence of a weekday, with optional `next` prefix and time. fn parse_weekday( tokens: &[&str], now: NaiveDateTime, default_time: NaiveTime, ) -> Option { let is_next = tokens.first() == Some(&"next"); let name_idx = usize::from(is_next); let target = WEEKDAYS .iter() .position(|d| Some(d) == tokens.get(name_idx))? as i64; let today = now.date(); let current = today.weekday().num_days_from_sunday() as i64; // Mirrors the previous JS offset logic exactly, including how "next" skips // an extra week. let mut diff = target - current; if diff <= 0 || is_next { diff += 7; } if is_next && diff <= 7 { diff += 7; } let time = parse_time_tokens(&tokens[name_idx + 1..], default_time)?; Some((today + Duration::days(diff)).and_time(time)) } /// Resolve a "month day" reference, rolling to next year if already past. fn parse_month_day( tokens: &[&str], now: NaiveDateTime, default_time: NaiveTime, ) -> Option { if tokens.len() < 2 { return None; } let month_idx = MONTH_ABBRS .iter() .position(|m| m == &tokens[0]) .or_else(|| MONTHS.iter().position(|m| m == &tokens[0]))?; let day: u32 = tokens[1].parse().ok()?; let year = now.year(); let mut date = NaiveDate::from_ymd_opt(year, month_idx as u32 + 1, day)?; // Compare at midnight (as the JS did) before applying the time-of-day. if date.and_hms_opt(0, 0, 0)? < now { date = NaiveDate::from_ymd_opt(year + 1, month_idx as u32 + 1, day)?; } let time = parse_time_tokens(&tokens[2..], default_time)?; Some(date.and_time(time)) } /// Parse an optional trailing time ("3pm", "3:30pm", "15:30", "3 pm"). /// /// Returns `default` when no tokens remain, or `None` when the trailing tokens /// are present but not a valid time (so the whole input is rejected, as the old /// anchored regexes did). fn parse_time_tokens(tokens: &[&str], default: NaiveTime) -> Option { if tokens.is_empty() { return Some(default); } parse_clock(&tokens.concat()) } /// Parse a clock string with an optional am/pm suffix and optional minutes. fn parse_clock(raw: &str) -> Option { let mut s = raw; let mut ampm: Option = None; // Some(true) = pm, Some(false) = am if let Some(stripped) = s.strip_suffix("pm") { ampm = Some(true); s = stripped; } else if let Some(stripped) = s.strip_suffix("am") { ampm = Some(false); s = stripped; } let s = s.trim(); let (h_str, m_str) = match s.split_once(':') { Some((h, m)) => (h, Some(m)), None => (s, None), }; let mut h: u32 = h_str.trim().parse().ok()?; let m: u32 = match m_str { Some(m) => m.trim().parse().ok()?, None => 0, }; match ampm { Some(true) if h < 12 => h += 12, Some(false) if h == 12 => h = 0, _ => {} } if h > 23 || m > 59 { return None; } NaiveTime::from_hms_opt(h, m, 0) } #[cfg(test)] mod tests { use super::*; /// Reference "now": Wednesday 2026-06-17 at 14:30 local. fn now() -> NaiveDateTime { NaiveDate::from_ymd_opt(2026, 6, 17) .unwrap() .and_hms_opt(14, 30, 0) .unwrap() } fn parse(s: &str) -> Option { parse_natural_date(s, now()) } #[test] fn relative_keywords() { assert_eq!( parse("today"), Some( NaiveDate::from_ymd_opt(2026, 6, 17) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); assert_eq!( parse("tomorrow"), Some( NaiveDate::from_ymd_opt(2026, 6, 18) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); assert_eq!( parse("yesterday"), Some( NaiveDate::from_ymd_opt(2026, 6, 16) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); } #[test] fn blank_and_garbage_are_none() { assert_eq!(parse(""), None); assert_eq!(parse(" "), None); assert_eq!(parse("someday maybe"), None); assert_eq!(parse("friday at noon"), None); // "at noon" is not a valid time suffix } #[test] fn iso_datetime_preserves_wall_clock() { assert_eq!( parse("2026-12-25T15:30"), Some( NaiveDate::from_ymd_opt(2026, 12, 25) .unwrap() .and_hms_opt(15, 30, 0) .unwrap() ) ); assert_eq!( parse("2026-12-25 15:30"), Some( NaiveDate::from_ymd_opt(2026, 12, 25) .unwrap() .and_hms_opt(15, 30, 0) .unwrap() ) ); } #[test] fn iso_date_defaults_to_nine_am() { assert_eq!( parse("2026-12-25"), Some( NaiveDate::from_ymd_opt(2026, 12, 25) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); assert_eq!( parse("2026-12-25 3pm"), Some( NaiveDate::from_ymd_opt(2026, 12, 25) .unwrap() .and_hms_opt(15, 0, 0) .unwrap() ) ); } #[test] fn in_n_days() { assert_eq!( parse("in 3 days"), Some( NaiveDate::from_ymd_opt(2026, 6, 20) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); assert_eq!( parse("in 1 day"), Some( NaiveDate::from_ymd_opt(2026, 6, 18) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); } #[test] fn next_week_is_next_monday() { // 2026-06-17 is a Wednesday; next Monday is 2026-06-22. assert_eq!( parse("next week"), Some( NaiveDate::from_ymd_opt(2026, 6, 22) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); } #[test] fn weekday_finds_next_occurrence() { // From Wednesday, "friday" is 2026-06-19. assert_eq!( parse("friday"), Some( NaiveDate::from_ymd_opt(2026, 6, 19) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); // Same weekday goes to next week (Wednesday -> 2026-06-24). assert_eq!( parse("wednesday"), Some( NaiveDate::from_ymd_opt(2026, 6, 24) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); // "friday 3pm" applies the time. assert_eq!( parse("friday 3pm"), Some( NaiveDate::from_ymd_opt(2026, 6, 19) .unwrap() .and_hms_opt(15, 0, 0) .unwrap() ) ); } #[test] fn next_weekday_skips_an_extra_week() { // "next friday" skips the coming Friday (matches the prior JS quirk): // coming Friday is 06-19, so "next friday" -> 06-26. assert_eq!( parse("next friday"), Some( NaiveDate::from_ymd_opt(2026, 6, 26) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); } #[test] fn month_day_rolls_to_next_year_when_past() { // June 17 is past at 14:30; "jan 5" rolls to 2027. assert_eq!( parse("jan 5"), Some( NaiveDate::from_ymd_opt(2027, 1, 5) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); // A future month stays this year. assert_eq!( parse("december 25"), Some( NaiveDate::from_ymd_opt(2026, 12, 25) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() ) ); assert_eq!( parse("dec 25 3pm"), Some( NaiveDate::from_ymd_opt(2026, 12, 25) .unwrap() .and_hms_opt(15, 0, 0) .unwrap() ) ); } #[test] fn clock_parsing_handles_am_pm_and_24h() { assert_eq!(parse_clock("3pm"), NaiveTime::from_hms_opt(15, 0, 0)); assert_eq!(parse_clock("3:30pm"), NaiveTime::from_hms_opt(15, 30, 0)); assert_eq!(parse_clock("12am"), NaiveTime::from_hms_opt(0, 0, 0)); assert_eq!(parse_clock("12pm"), NaiveTime::from_hms_opt(12, 0, 0)); assert_eq!(parse_clock("15:30"), NaiveTime::from_hms_opt(15, 30, 0)); assert_eq!(parse_clock("25:00"), None); assert_eq!(parse_clock("noon"), None); } }