Skip to main content

timelog/date/
parse.rs

1//! Module implementing parsing of human-readable text into a [`DateRange`].
2//!
3//! # Examples
4//!
5//! ```rust
6//! use timelog::date::{DateRange, RangeParser};
7//! use timelog::Result;
8//!
9//! # fn main() -> Result<()> {
10//! let parser = RangeParser::default();
11//! let range = parser.parse_from_str("last week")?;
12//! print!("Dates: {} up to {}", range.start(), range.end());
13//! #   Ok(())
14//! # }
15//! ```
16//!
17//! # Description
18//!
19//! The [`RangeParser`] converts a description of relative or absolute dates into a
20//! range of dates expressed as a half-open range including the start date, but excluding
21//! the end date.
22//!
23//! # Date Range Descriptors
24//!
25//! A date range descriptor can be supplied to the parser either as a &str containing the
26//! descriptor or as an iterator returning separate words of the descriptor.
27//!
28//! The parser can handle date range descriptors of the following forms:
29//!
30//! A single date in one of the following forms:
31//!
32//! * yyyy-mm-dd
33//! * today
34//! * yesterday
35//! * a day of the week: sunday, monday, tuesday, wednesday, ... etc.
36//!
37//! All but the first of the above strings parse to dates relative to the base date of the
38//! parser. "Today" is the current date. "Yesterday" is the day before the current date. The
39//! weekdays are the last instance of that day before today.
40//!
41//! This will result in a range spanning one day.
42//!
43//! A pair of dates in the forms above.
44//!
45//! This pair results in a range that covers the two supplied dates. If the dates are in order,
46//! the start is the first date and end is the day after the second date. If the dates are out of
47//! order, the parse will return an empty range.
48//!
49//! A range description in one of the following forms:
50//!
51//! * a month name: january, february, march, ... etc.
52//! * a short (3 char) month name: jan, feb, mar, ... etc.
53//! * a relative timeframe: (this|last) (week|month|year)
54//! * the string "ytd"
55//!
56//! If the parser is given a month name, the range will cover the whole month with that name
57//! before today.
58//!
59//! If the parser is given "this" followed by "week", "month", or "year", the resultant range
60//! covers:
61//!
62//! * week: from the last Sunday to Saturday of this week,
63//! * month: from the first day of the current month to last day of the month,
64//! * year: from the January 1 of the current year to the last day of the year.
65//!
66//! If the parser is given "last" followed by "week", "month", or "year", the resultant range
67//! covers:
68//!
69//! * week: from the two Sundays ago to last Saturday,
70//! * month: from the first of the month before this one to the last day of that month,
71//! * year: from January 1 of the year before the current one to December 31 of the same year.
72//!
73//! If the parser receives the string "ytd", the range will be from January 1 of the current year
74//! up to today.
75
76use chrono::Weekday;
77
78use crate::date::{self, Date, DateError, DateRange};
79
80/// Struct implementing the parser to generate a [`Date`] from a date description.
81pub struct DateParser {
82    base: Date
83}
84
85impl Default for DateParser {
86    /// Return a [`DateParser`], parsing relative to today.
87    fn default() -> Self { Self { base: Date::today() } }
88}
89
90impl DateParser {
91    // Return a [`RangeParser`] with the specified base date.
92    //
93    // Explicitly not public, since it's used for testing.
94    fn new(base: Date) -> Self { Self { base } }
95
96    /// Parse the supplied string to return a [`Date`] if successful.
97    ///
98    /// # Errors
99    ///
100    /// - Return [`DateError::InvalidDate`] if the specification for a single day is not valid.
101    /// - Return [`DateError::InvalidDaySpec`] if overall range specification is not valid.
102    pub fn parse(&self, datespec: &str) -> date::Result<Date> {
103        match datespec {
104            "today" => Ok(self.base),
105            "yesterday" => Ok(self.base.pred()),
106            "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" => {
107                Ok(self.base.find_previous(weekday_from_str(datespec)?))
108            }
109            _ => datespec.parse()
110        }
111    }
112}
113
114/// Struct implementing the parser to generate a [`DateRange`] from a date range description.
115pub struct RangeParser {
116    base: Date
117}
118
119impl Default for RangeParser {
120    /// Return a [`RangeParser`], parsing relative to today.
121    fn default() -> Self { Self { base: Date::today() } }
122}
123
124// Convert weekday string into appropriate Weekday variant.
125fn weekday_from_str(day: &str) -> date::Result<Weekday> {
126    match day {
127        "sunday"    => Ok(Weekday::Sun),
128        "monday"    => Ok(Weekday::Mon),
129        "tuesday"   => Ok(Weekday::Tue),
130        "wednesday" => Ok(Weekday::Wed),
131        "thursday"  => Ok(Weekday::Thu),
132        "friday"    => Ok(Weekday::Fri),
133        "saturday"  => Ok(Weekday::Sat),
134        _ => Err(DateError::InvalidDaySpec(day.to_string()))
135    }
136}
137
138fn month_from_name(name: &str) -> Option<u32> {
139    let month = match name {
140        "january" | "jan" => 1,
141        "february" | "feb" => 2,
142        "march" | "mar" => 3,
143        "april" | "apr" => 4,
144        "may" => 5,
145        "june" | "jun" => 6,
146        "july" | "jul" => 7,
147        "august" | "aug" => 8,
148        "september" | "sep" | "sept" => 9,
149        "october" | "oct" => 10,
150        "november" | "nov" => 11,
151        "december" | "dec" => 12,
152        _ => return None
153    };
154    Some(month)
155}
156
157impl RangeParser {
158    // Return a [`RangeParser`] with the specified base date.
159    #[cfg(test)]
160    pub fn new(base: Date) -> Self { Self { base } }
161
162    /// Parse the supplied string to return a [`DateRange`] if successful.
163    ///
164    /// # Errors
165    ///
166    /// - Return [`DateError::InvalidDate`] if the specification for a single day is not valid.
167    /// - Return [`DateError::InvalidDaySpec`] if overall range specification is not valid.
168    pub fn parse_from_str(&self, datespec: &str) -> date::Result<DateRange> {
169        if datespec.is_empty() { return Ok(self.base.into()); }
170        let mut iter = datespec.split_ascii_whitespace();
171        self.parse(&mut iter).map(|(r, _)| r)
172    }
173
174    /// Parse the tokens from the supplied string iterator to return a tuple containing
175    /// a [`DateRange`] and the last unused token if successful.
176    ///
177    /// # Errors
178    ///
179    /// - Return [`DateError::InvalidDate`] if the specification for a single day is not valid.
180    /// - Return [`DateError::InvalidDaySpec`] if overall range specification is not valid.
181    pub fn parse<'a, I>(&self, datespec: &mut I) -> date::Result<(DateRange, &'a str)>
182    where
183        I: Iterator<Item = &'a str>
184    {
185        let Some(token) = datespec.next() else {
186            return Ok((self.base.into(), ""));
187        };
188        let ltoken = token.to_ascii_lowercase();
189        // Parse month name
190        if let Some(range) = self.month_range(ltoken.as_str()) {
191            return Ok((range, ""));
192        }
193
194        let base = self.base;
195        let range_opt = match ltoken.as_str() {
196            "ytd" => {
197                let start = base.year_start();
198                DateRange::new_opt(start, base.succ())
199            }
200            "this" => {
201                let Some(token) = datespec.next() else {
202                    return Err(DateError::InvalidDaySpec(token.into()));
203                };
204                let ltoken = token.to_ascii_lowercase();
205                match ltoken.as_str() {
206                    "week" => DateRange::new_opt(base.week_start(), base.week_end().succ()),
207                    "month" => DateRange::new_opt(base.month_start(), base.month_end().succ()),
208                    "year" => DateRange::new_opt(base.year_start(), base.year_end().succ()),
209                    _ => return Err(DateError::InvalidDaySpec(token.into()))
210                }
211            }
212            "last" => {
213                let Some(token) = datespec.next() else {
214                    return Err(DateError::InvalidDate);
215                };
216                let ltoken = token.to_ascii_lowercase();
217                match ltoken.as_str() {
218                    "week" => {
219                        let date = base.week_start();
220                        DateRange::new_opt(date.pred().week_start(), date)
221                    }
222                    "month" => {
223                        let date = base.month_start().pred().month_start();
224                        DateRange::new_opt(date, date.month_end().succ())
225                    }
226                    "year" => {
227                        let date = Date::new(base.year() - 1, 1, 1)?;
228                        DateRange::new_opt(date, date.year_end().succ())
229                    }
230                    _ => return Err(DateError::InvalidDaySpec(token.into()))
231                }
232            }
233            _ => None
234        };
235
236        if let Some(date_range) = range_opt {
237            return Ok((date_range, ""));
238        }
239
240        // Parse one or two dates
241        let dparser = DateParser::new(self.base);
242        let Ok(start) = dparser.parse(&ltoken) else {
243            return Ok((self.base.into(), token));
244        };
245        if let Some(token) = datespec.next() {
246            let ltoken = token.to_ascii_lowercase();
247            if let Ok(end) = dparser.parse(&ltoken) {
248                let range = DateRange::new_opt(start, end.succ())
249                    .ok_or(DateError::WrongDateOrder)?;
250                return Ok((range, ""));
251            }
252            else {
253                return Ok((start.into(), token));
254            }
255        }
256
257        Ok((start.into(), ""))
258    }
259
260    // Utility method to parse a month name
261    fn month_range(&self, token: &str) -> Option<DateRange> {
262        let month = month_from_name(token)?;
263        let this = self.base.month();
264        let year = self.base.year();
265        let year = if month < this { year } else { year - 1 };
266
267        let start = Date::new(year, month, 1).ok()?;
268        Some(DateRange { start, end: start.month_end().succ() })
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use once_cell::sync::Lazy;
275    use assert2::{assert, let_assert};
276    use rstest::rstest;
277
278    use super::*;
279    use crate::date::{DateTime, Weekday};
280
281    static BASE_DATE: Lazy<Date> = Lazy::new(
282        || Date::new(2022, 11, 15).expect("Hardcoded value") // tuesday
283    );
284    static YESTERDAY: Lazy<Date> = Lazy::new(
285        ||Date::new(2022, 11, 14).expect("Hardcoded date must work")
286    );
287    static HARD_DATE: Lazy<Date> = Lazy::new(
288        ||Date::new(2022, 10, 20).expect("Hardcoded date must work")
289    );
290
291    // DateParser tests
292
293    #[rstest]
294    #[case("today", Date::today(), "today")]
295    #[case("yesterday", Date::today().pred(), "yesterday")]
296    fn test_date_parse(#[case]input: &str, #[case]expected: Date, #[case]msg: &str) {
297        let p = DateParser::default();
298        let_assert!(Ok(actual) = p.parse(input));
299        assert!(actual == expected, "{msg}");
300    }
301
302    #[test]
303    fn test_date_parse_weekdays() {
304        let max_dur = DateTime::days(7);
305        #[rustfmt::skip]
306        let days: [&str; 7] = [
307            "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"
308        ];
309        let today = Date::today();
310        let midnight = Date::today().day_end();
311        let p = DateParser::default();
312        days.iter().for_each(|&day| {
313            let_assert!(Ok(date) = p.parse(day), "parse {day}");
314            assert!(date < today);
315
316            let end_of_date = date.day_end();
317            let_assert!(Ok(dur) = midnight - end_of_date, "end {day}");
318            assert!(dur <= max_dur);
319        });
320    }
321
322    // RangeParser tests
323
324    fn test_range_parser() -> RangeParser { RangeParser::new(*BASE_DATE) }
325
326    #[test]
327    fn test_parse_default() {
328        let p = RangeParser::default();
329        let expect: DateRange = DateRange::default();
330        let_assert!(Ok(actual) = p.parse_from_str(""));
331        assert!(actual == expect);
332    }
333
334    #[rstest]
335    #[case("", (*BASE_DATE).into(), "new")]
336    #[case("today", (*BASE_DATE).into(), "today")]
337    #[case("yesterday", (*YESTERDAY).into(), "yesterday")]
338    #[case("2022-10-20", (*HARD_DATE).into(), "actual date")]
339    #[case("monday", DateRange::from(BASE_DATE.find_previous(Weekday::Mon)), "dayname")]
340    #[case("wednesday", DateRange::from(BASE_DATE.find_previous(Weekday::Wed)), "later dayname")]
341    fn test_date_range_parser_one_day(#[case]input: &str, #[case]expect: DateRange, #[case]msg: &str) {
342        let p = test_range_parser();
343        let_assert!(Ok(actual) = p.parse_from_str(input));
344        assert!(actual == expect, "{msg}");
345    }
346
347    // two date parses
348
349    #[test]
350    fn test_dates_both_dates() {
351        let_assert!(Ok(start) = Date::new(2021, 12, 1));
352        let_assert!(Ok(end) = Date::new(2021, 12, 8));
353        let expected = DateRange::new(start, end);
354
355        let p = test_range_parser();
356        let_assert!(Ok(actual) = p.parse_from_str("2021-12-01 2021-12-07"));
357        assert!(actual == expected);
358    }
359
360    #[test]
361    fn test_dates_both_dates_desc() {
362        let_assert!(Ok(start) = Date::new(2022, 11, 13));
363        let expected = DateRange::new(start, BASE_DATE.succ());
364
365        let p = test_range_parser();
366        let_assert!(Ok(actual) = p.parse_from_str("sunday today"));
367        assert!(actual == expected);
368    }
369
370    // relative range parses
371
372    #[rstest]
373    #[case("january", Date::new(2022, 1, 1), Date::new(2022, 2, 1))]
374    #[case("jan", Date::new(2022, 1, 1), Date::new(2022, 2, 1))]
375    #[case("february", Date::new(2022, 2, 1), Date::new(2022, 3, 1))]
376    #[case("feb", Date::new(2022, 2, 1), Date::new(2022, 3, 1))]
377    #[case("march", Date::new(2022, 3, 1), Date::new(2022, 4, 1))]
378    #[case("mar", Date::new(2022, 3, 1), Date::new(2022, 4, 1))]
379    #[case("april", Date::new(2022, 4, 1), Date::new(2022, 5, 1))]
380    #[case("apr", Date::new(2022, 4, 1), Date::new(2022, 5, 1))]
381    #[case("may", Date::new(2022, 5, 1), Date::new(2022, 6, 1))]
382    #[case("june", Date::new(2022, 6, 1), Date::new(2022, 7, 1))]
383    #[case("jun", Date::new(2022, 6, 1), Date::new(2022, 7, 1))]
384    #[case("july", Date::new(2022, 7, 1), Date::new(2022, 8, 1))]
385    #[case("jul", Date::new(2022, 7, 1), Date::new(2022, 8, 1))]
386    #[case("august", Date::new(2022, 8, 1), Date::new(2022, 9, 1))]
387    #[case("aug", Date::new(2022, 8, 1), Date::new(2022, 9, 1))]
388    #[case("september", Date::new(2022, 9, 1), Date::new(2022, 10, 1))]
389    #[case("sep", Date::new(2022, 9, 1), Date::new(2022, 10, 1))]
390    #[case("october", Date::new(2022, 10, 1), Date::new(2022, 11, 1))]
391    #[case("oct", Date::new(2022, 10, 1), Date::new(2022, 11, 1))]
392    #[case("november", Date::new(2021, 11, 1), Date::new(2021, 12, 1))]
393    #[case("nov", Date::new(2021, 11, 1), Date::new(2021, 12, 1))]
394    #[case("december", Date::new(2021, 12, 1), Date::new(2022, 1, 1))]
395    #[case("dec", Date::new(2021, 12, 1), Date::new(2022, 1, 1))]
396    fn test_month_name(
397        #[case]name: &str,
398        #[case]start_opt: Result<Date, DateError>,
399        #[case]end_opt: Result<Date, DateError>
400    ) {
401        let p = test_range_parser();
402        let_assert!(Ok(start) = start_opt.as_ref());
403        let_assert!(Ok(end) = end_opt.as_ref());
404        let expected = DateRange::new(*start, *end);
405        let_assert!(Ok(actual) = p.parse_from_str(name));
406        assert!(actual == expected);
407    }
408
409    #[rstest]
410    #[case("this week", Date::new(2022, 11, 13), Date::new(2022, 11, 20))]
411    #[case("this month", Date::new(2022, 11, 1), Date::new(2022, 12, 1))]
412    #[case("this year", Date::new(2022, 1, 1), Date::new(2023, 1, 1))]
413    #[case("ytd", Date::new(2022, 1, 1), Ok(BASE_DATE.succ()))]
414    #[case("last week", Date::new(2022, 11, 6), Date::new(2022, 11, 13))]
415    #[case("last month", Date::new(2022, 10, 1), Date::new(2022, 11, 1))]
416    #[case("last year", Date::new(2021, 1, 1), Date::new(2022, 1, 1))]
417    fn test_special_range(
418        #[case]input: &str,
419        #[case]start_opt: Result<Date, DateError>,
420        #[case]end_opt: Result<Date, DateError>
421    ) {
422        let_assert!(Ok(start) = start_opt);
423        let_assert!(Ok(end) = end_opt);
424        let expected = DateRange::new(start, end);
425
426        let p = test_range_parser();
427        let_assert!(Ok(actual) = p.parse_from_str(input), "parsing '{input}'");
428        assert!(actual == expected);
429    }
430}