Skip to main content

smriti/search/
date_parser.rs

1//! Natural language date parsing.
2
3use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Utc};
4
5/// A parsed date range in UTC.
6#[derive(Debug, Clone)]
7pub struct DateRange {
8    pub start: chrono::DateTime<Utc>,
9    pub end: chrono::DateTime<Utc>,
10}
11
12impl DateRange {
13    pub fn single_day(date: NaiveDate) -> Self {
14        Self {
15            start: Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).expect("valid hms")),
16            end: Utc.from_utc_datetime(&date.and_hms_opt(23, 59, 59).expect("valid hms")),
17        }
18    }
19
20    pub fn month(year: i32, month: u32) -> Option<Self> {
21        let start_date = NaiveDate::from_ymd_opt(year, month, 1)?;
22        let end_date = if month == 12 {
23            NaiveDate::from_ymd_opt(year + 1, 1, 1)?.pred_opt()?
24        } else {
25            NaiveDate::from_ymd_opt(year, month + 1, 1)?.pred_opt()?
26        };
27
28        Some(Self {
29            start: Utc.from_utc_datetime(&start_date.and_hms_opt(0, 0, 0).expect("valid hms")),
30            end: Utc.from_utc_datetime(&end_date.and_hms_opt(23, 59, 59).expect("valid hms")),
31        })
32    }
33
34    pub fn year(year: i32) -> Option<Self> {
35        let start = NaiveDate::from_ymd_opt(year, 1, 1)?;
36        let end = NaiveDate::from_ymd_opt(year, 12, 31)?;
37
38        Some(Self {
39            start: Utc.from_utc_datetime(&start.and_hms_opt(0, 0, 0).expect("valid hms")),
40            end: Utc.from_utc_datetime(&end.and_hms_opt(23, 59, 59).expect("valid hms")),
41        })
42    }
43}
44
45/// Natural language date parser.
46pub struct DateParser;
47
48impl DateParser {
49    pub fn parse(input: &str) -> Option<DateRange> {
50        let s = input.trim().to_lowercase();
51        if s.is_empty() {
52            return None;
53        }
54        let today = Local::now().date_naive();
55
56        Self::parse_relative(&s, today)
57            .or_else(|| Self::parse_month_year(&s))
58            .or_else(|| Self::parse_year(&s))
59            .or_else(|| Self::parse_season(&s, today))
60            .or_else(|| Self::parse_month_only(&s, today))
61            .or_else(|| Self::parse_iso_date(&s))
62    }
63
64    fn parse_relative(input: &str, today: NaiveDate) -> Option<DateRange> {
65        match input {
66            "today" => Some(DateRange::single_day(today)),
67            "yesterday" => Some(DateRange::single_day(today - Duration::days(1))),
68            "this week" => {
69                let start = today - Duration::days(today.weekday().num_days_from_monday() as i64);
70                let end = start + Duration::days(6);
71                Some(DateRange {
72                    start: Utc.from_utc_datetime(&start.and_hms_opt(0, 0, 0).expect("valid hms")),
73                    end: Utc.from_utc_datetime(&end.and_hms_opt(23, 59, 59).expect("valid hms")),
74                })
75            }
76            "last week" => {
77                let this_week_start =
78                    today - Duration::days(today.weekday().num_days_from_monday() as i64);
79                let start = this_week_start - Duration::days(7);
80                let end = this_week_start - Duration::days(1);
81                Some(DateRange {
82                    start: Utc.from_utc_datetime(&start.and_hms_opt(0, 0, 0).expect("valid hms")),
83                    end: Utc.from_utc_datetime(&end.and_hms_opt(23, 59, 59).expect("valid hms")),
84                })
85            }
86            "this month" => DateRange::month(today.year(), today.month()),
87            "last month" => {
88                let (year, month) = if today.month() == 1 {
89                    (today.year() - 1, 12)
90                } else {
91                    (today.year(), today.month() - 1)
92                };
93                DateRange::month(year, month)
94            }
95            "this year" => DateRange::year(today.year()),
96            "last year" => DateRange::year(today.year() - 1),
97            _ => None,
98        }
99    }
100
101    fn parse_month_year(input: &str) -> Option<DateRange> {
102        let parts: Vec<&str> = input.split_whitespace().collect();
103        if parts.len() != 2 {
104            return None;
105        }
106
107        let (month_str, year_str) = if parts[0].parse::<i32>().is_ok() {
108            (parts[1], parts[0])
109        } else {
110            (parts[0], parts[1])
111        };
112
113        let year: i32 = year_str.parse().ok()?;
114        let month = month_name_to_number(month_str)?;
115        DateRange::month(year, month)
116    }
117
118    fn parse_year(input: &str) -> Option<DateRange> {
119        let year: i32 = input.parse().ok()?;
120        if !(1900..=2100).contains(&year) {
121            return None;
122        }
123        DateRange::year(year)
124    }
125
126    fn parse_season(input: &str, today: NaiveDate) -> Option<DateRange> {
127        if let Some(season_name) = input.strip_prefix("last ") {
128            let (start_month, end_month) = season_bounds(season_name)?;
129            let year = if start_month > today.month() {
130                today.year() - 2
131            } else {
132                today.year() - 1
133            };
134            return Self::season_range(year, start_month, end_month);
135        }
136
137        if let Some(season_name) = input.strip_prefix("this ") {
138            let (start_month, end_month) = season_bounds(season_name)?;
139            return Self::season_range(today.year(), start_month, end_month);
140        }
141
142        let parts: Vec<&str> = input.split_whitespace().collect();
143        if parts.len() == 2 {
144            let (start_month, end_month) = season_bounds(parts[0])?;
145            let year: i32 = parts[1].parse().ok()?;
146            return Self::season_range(year, start_month, end_month);
147        }
148
149        None
150    }
151
152    fn season_range(year: i32, start_month: u32, end_month: u32) -> Option<DateRange> {
153        let (start_year, end_year) = if start_month > end_month {
154            (year, year + 1)
155        } else {
156            (year, year)
157        };
158
159        let start = NaiveDate::from_ymd_opt(start_year, start_month, 1)?;
160        let end_date = if end_month == 12 {
161            NaiveDate::from_ymd_opt(end_year + 1, 1, 1)?.pred_opt()?
162        } else {
163            NaiveDate::from_ymd_opt(end_year, end_month + 1, 1)?.pred_opt()?
164        };
165
166        Some(DateRange {
167            start: Utc.from_utc_datetime(&start.and_hms_opt(0, 0, 0).expect("valid hms")),
168            end: Utc.from_utc_datetime(&end_date.and_hms_opt(23, 59, 59).expect("valid hms")),
169        })
170    }
171
172    fn parse_month_only(input: &str, today: NaiveDate) -> Option<DateRange> {
173        let month = month_name_to_number(input)?;
174        DateRange::month(today.year(), month)
175    }
176
177    fn parse_iso_date(input: &str) -> Option<DateRange> {
178        let date = NaiveDate::parse_from_str(input, "%Y-%m-%d").ok()?;
179        Some(DateRange::single_day(date))
180    }
181}
182
183fn month_name_to_number(s: &str) -> Option<u32> {
184    match s {
185        "january" | "jan" => Some(1),
186        "february" | "feb" => Some(2),
187        "march" | "mar" => Some(3),
188        "april" | "apr" => Some(4),
189        "may" => Some(5),
190        "june" | "jun" => Some(6),
191        "july" | "jul" => Some(7),
192        "august" | "aug" => Some(8),
193        "september" | "sep" | "sept" => Some(9),
194        "october" | "oct" => Some(10),
195        "november" | "nov" => Some(11),
196        "december" | "dec" => Some(12),
197        _ => None,
198    }
199}
200
201fn season_bounds(s: &str) -> Option<(u32, u32)> {
202    match s {
203        "spring" => Some((3, 5)),
204        "summer" => Some((6, 8)),
205        "fall" | "autumn" => Some((9, 11)),
206        "winter" => Some((12, 2)),
207        _ => None,
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use chrono::Datelike;
215
216    #[test]
217    fn test_parse_month_year() {
218        let range = DateParser::parse("March 2019").expect("range");
219        assert_eq!(range.start.year(), 2019);
220        assert_eq!(range.start.month(), 3);
221        assert_eq!(range.end.month(), 3);
222    }
223
224    #[test]
225    fn test_parse_yesterday() {
226        let range = DateParser::parse("yesterday").expect("range");
227        assert!(range.end >= range.start);
228    }
229
230    #[test]
231    fn test_parse_last_summer() {
232        let range = DateParser::parse("last summer").expect("range");
233        assert!(range.end >= range.start);
234    }
235}