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            _ => Date::try_from(datespec)
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 range_opt = match ltoken.as_str() {
195            "ytd" => {
196                let start = self.base.year_start();
197                DateRange::new_opt(start, self.base.succ())
198            },
199            "this" => {
200                let Some(token) = datespec.next() else {
201                    return Err(DateError::InvalidDaySpec(token.into()));
202                };
203                let ltoken = token.to_ascii_lowercase();
204                match ltoken.as_str()  {
205                    "week" => DateRange::new_opt(self.base.week_start(), self.base.week_end().succ()),
206                    "month" => DateRange::new_opt(self.base.month_start(), self.base.month_end().succ()),
207                    "year" => DateRange::new_opt(self.base.year_start(), self.base.year_end().succ()),
208                    _ => return Err(DateError::InvalidDaySpec(token.into())),
209                }
210            },
211            "last" => {
212                let Some(token) = datespec.next() else {
213                    return Err(DateError::InvalidDate);
214                };
215                let ltoken = token.to_ascii_lowercase();
216                match ltoken.as_str() {
217                    "week" => {
218                        let date = self.base.week_start();
219                        DateRange::new_opt(date.pred().week_start(), date)
220                    },
221                    "month" => {
222                        let date = self.base.month_start().pred().month_start();
223                        DateRange::new_opt(date, date.month_end().succ())
224                    },
225                    "year" => {
226                        let date = Date::new(self.base.year() - 1, 1, 1)?;
227                        DateRange::new_opt(date, date.year_end().succ())
228                    }
229                    _ => return Err(DateError::InvalidDaySpec(token.into())),
230                }
231            }
232            _ => None
233        };
234
235        if let Some(date_range) = range_opt {
236            return Ok((date_range, ""));
237        }
238
239        // Parse one or two dates
240        let dparser = DateParser::new(self.base);
241        let Ok(start) = dparser.parse(&ltoken) else {
242            return Ok((self.base.into(), token));
243        };
244        if let Some(token) = datespec.next() {
245            let ltoken = token.to_ascii_lowercase();
246            if let Ok(end) = dparser.parse(&ltoken) {
247                let range =
248                    DateRange::new_opt(start, end.succ()).ok_or(DateError::WrongDateOrder)?;
249                return Ok((range, ""));
250            }
251            else {
252                return Ok((start.into(), token));
253            }
254        }
255
256        Ok((start.into(), ""))
257    }
258
259    // Utility method to parse a month name
260    fn month_range(&self, token: &str) -> Option<DateRange> {
261        let month = month_from_name(token)?;
262        let this = self.base.month();
263        let year = self.base.year();
264        let year = if month < this { year } else { year - 1 };
265
266        let start = Date::new(year, month, 1).ok()?;
267        Some(DateRange { start, end: start.month_end().succ() })
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use once_cell::sync::Lazy;
274    use spectral::prelude::*;
275
276    use super::*;
277    use crate::date::{DateTime, Weekday};
278
279    static BASE_DATE: Lazy<Date> = Lazy::new(
280        || Date::new(2022, 11, 15).unwrap() // tuesday
281    );
282
283    // DateParser tests
284
285    #[test]
286    fn test_date_parse_today() {
287        let p = DateParser::default();
288        assert_that!(p.parse("today"))
289            .is_ok()
290            .is_equal_to(&Date::today());
291    }
292
293    #[test]
294    fn test_date_parse_yesterday() {
295        let p = DateParser::default();
296        assert_that!(p.parse("yesterday"))
297            .is_ok()
298            .is_equal_to(&Date::today().pred());
299    }
300
301    #[test]
302    fn test_date_parse_weekdays() {
303        let max_dur = DateTime::days(7);
304        #[rustfmt::skip]
305        let days: [&str; 7] = [
306            "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"
307        ];
308        let today = Date::today();
309        let midnight = Date::today().day_end();
310        let p = DateParser::default();
311        days.iter().for_each(|&day| {
312            let date = p.parse(day);
313            assert_that!(date).named(day).is_ok().is_less_than(&today);
314
315            let end_of_date = date.unwrap().day_end();
316            assert_that!(midnight - end_of_date)
317                .named(day)
318                .is_ok()
319                .is_less_than_or_equal_to(&max_dur);
320        });
321    }
322
323    // RangeParser tests
324
325    fn test_range_parser() -> RangeParser { RangeParser::new(*BASE_DATE) }
326
327    #[test]
328    fn test_parse_default() {
329        let p = RangeParser::default();
330        let expect: DateRange = DateRange::default();
331        assert_that!(p.parse_from_str(""))
332            .is_ok()
333            .is_equal_to(&expect);
334    }
335
336    #[test]
337    fn test_parse_new() {
338        let p = test_range_parser();
339        let expect: DateRange = (*BASE_DATE).into();
340        assert_that!(p.parse_from_str(""))
341            .is_ok()
342            .is_equal_to(&expect);
343    }
344
345    // Single day parses
346
347    #[test]
348    fn test_parse_today() {
349        let p = test_range_parser();
350        let expect: DateRange = (*BASE_DATE).into();
351        assert_that!(p.parse_from_str("today"))
352            .is_ok()
353            .is_equal_to(&expect);
354    }
355
356    #[test]
357    fn test_parse_yesterday() {
358        let p = test_range_parser();
359        let expect: DateRange =
360            Date::new(2022, 11, 14).expect("Hardcoded date must work").into();
361
362        assert_that!(p.parse_from_str("yesterday"))
363            .is_ok()
364            .is_equal_to(&expect);
365    }
366
367    #[test]
368    fn test_parse_date() {
369        let p = RangeParser::default();
370        let expect: DateRange =
371            Date::new(2022, 10, 20).expect("Hardcoded date must work").into();
372
373        assert_that!(p.parse_from_str("2022-10-20"))
374            .is_ok()
375            .is_equal_to(&expect);
376    }
377
378    #[test]
379    fn test_parse_dayname() {
380        let p = test_range_parser();
381        assert_that!(p.parse_from_str("monday"))
382            .is_ok()
383            .is_equal_to(&BASE_DATE.find_previous(Weekday::Mon).into());
384    }
385
386    #[test]
387    fn test_parse_later_dayname() {
388        let p = test_range_parser();
389        assert_that!(p.parse_from_str("wednesday"))
390            .is_ok()
391            .is_equal_to(&BASE_DATE.find_previous(Weekday::Wed).into());
392    }
393
394    // two date parses
395
396    #[test]
397    fn test_dates_both_dates() {
398        let expected = DateRange::new(
399            Date::new(2021, 12, 1).unwrap(),
400            Date::new(2021, 12, 8).unwrap()
401        );
402
403        let p = test_range_parser();
404        assert_that!(p.parse_from_str("2021-12-01 2021-12-07"))
405            .is_ok()
406            .is_equal_to(&expected);
407    }
408
409    #[test]
410    fn test_dates_both_dates_desc() {
411        let expected = DateRange::new(
412            Date::new(2022, 11, 13).unwrap(),
413            BASE_DATE.succ()
414        );
415
416        let p = test_range_parser();
417        assert_that!(p.parse_from_str("sunday today"))
418            .is_ok()
419            .is_equal_to(&expected);
420    }
421
422    // relative range parses
423
424    #[test]
425    fn test_month_name() {
426        let tests = [
427            ("january", Date::new(2022, 1, 1), Date::new(2022, 2, 1)),
428            ("jan", Date::new(2022, 1, 1), Date::new(2022, 2, 1)),
429            ("february", Date::new(2022, 2, 1), Date::new(2022, 3, 1)),
430            ("feb", Date::new(2022, 2, 1), Date::new(2022, 3, 1)),
431            ("march", Date::new(2022, 3, 1), Date::new(2022, 4, 1)),
432            ("mar", Date::new(2022, 3, 1), Date::new(2022, 4, 1)),
433            ("april", Date::new(2022, 4, 1), Date::new(2022, 5, 1)),
434            ("apr", Date::new(2022, 4, 1), Date::new(2022, 5, 1)),
435            ("may", Date::new(2022, 5, 1), Date::new(2022, 6, 1)),
436            ("june", Date::new(2022, 6, 1), Date::new(2022, 7, 1)),
437            ("jun", Date::new(2022, 6, 1), Date::new(2022, 7, 1)),
438            ("july", Date::new(2022, 7, 1), Date::new(2022, 8, 1)),
439            ("jul", Date::new(2022, 7, 1), Date::new(2022, 8, 1)),
440            ("august", Date::new(2022, 8, 1), Date::new(2022, 9, 1)),
441            ("aug", Date::new(2022, 8, 1), Date::new(2022, 9, 1)),
442            ("september", Date::new(2022, 9, 1), Date::new(2022, 10, 1)),
443            ("sep", Date::new(2022, 9, 1), Date::new(2022, 10, 1)),
444            ("october", Date::new(2022, 10, 1), Date::new(2022, 11, 1)),
445            ("oct", Date::new(2022, 10, 1), Date::new(2022, 11, 1)),
446            ("november", Date::new(2021, 11, 1), Date::new(2021, 12, 1)),
447            ("nov", Date::new(2021, 11, 1), Date::new(2021, 12, 1)),
448            ("december", Date::new(2021, 12, 1), Date::new(2022, 1, 1)),
449            ("dec", Date::new(2021, 12, 1), Date::new(2022, 1, 1))
450        ];
451
452        let p = test_range_parser();
453        for (name, start_opt, end_opt) in tests.iter() {
454            let start = start_opt.as_ref().unwrap();
455            let end = end_opt.as_ref().unwrap();
456            let expected = DateRange::new(*start, *end);
457            assert_that!(p.parse_from_str(name))
458                .named(&name)
459                .is_ok()
460                .is_equal_to(&expected);
461        }
462    }
463
464    #[test]
465    fn test_this_week() {
466        let expected = DateRange::new(
467            Date::new(2022, 11, 13).unwrap(),
468            Date::new(2022, 11, 20).unwrap()
469        );
470
471        let p = test_range_parser();
472        assert_that!(p.parse_from_str("this week"))
473            .is_ok()
474            .is_equal_to(&expected);
475    }
476
477    #[test]
478    fn test_this_month() {
479        let expected = DateRange::new(
480            Date::new(2022, 11, 1).unwrap(),
481            Date::new(2022, 12, 1).unwrap()
482        );
483
484        let p = test_range_parser();
485        assert_that!(p.parse_from_str("this month"))
486            .is_ok()
487            .is_equal_to(&expected);
488    }
489
490    #[test]
491    fn test_this_year() {
492        let expected = DateRange::new(
493            Date::new(2022, 1, 1).unwrap(),
494            Date::new(2023, 1, 1).unwrap()
495        );
496
497        let p = test_range_parser();
498        assert_that!(p.parse_from_str("this year"))
499            .is_ok()
500            .is_equal_to(&expected);
501    }
502
503    #[test]
504    fn test_ytd() {
505        let expected = DateRange::new(Date::new(2022, 1, 1).unwrap(), BASE_DATE.succ());
506
507        let p = test_range_parser();
508        assert_that!(p.parse_from_str("ytd"))
509            .is_ok()
510            .is_equal_to(&expected);
511    }
512
513    #[test]
514    fn test_last_week() {
515        let expected = DateRange::new(
516            Date::new(2022, 11, 6).unwrap(),
517            Date::new(2022, 11, 13).unwrap()
518        );
519
520        let p = test_range_parser();
521        assert_that!(p.parse_from_str("last week"))
522            .is_ok()
523            .is_equal_to(&expected);
524    }
525
526    #[test]
527    fn test_last_month() {
528        let expected = DateRange::new(
529            Date::new(2022, 10, 1).unwrap(),
530            Date::new(2022, 11, 1).unwrap()
531        );
532
533        let p = test_range_parser();
534        assert_that!(p.parse_from_str("last month"))
535            .is_ok()
536            .is_equal_to(&expected);
537    }
538
539    #[test]
540    fn test_last_year() {
541        let expected = DateRange::new(
542            Date::new(2021, 1, 1).unwrap(),
543            Date::new(2022, 1, 1).unwrap()
544        );
545
546        let p = test_range_parser();
547        assert_that!(p.parse_from_str("last year"))
548            .is_ok()
549            .is_equal_to(&expected);
550    }
551}