Skip to main content

doing_time/
range.rs

1use std::sync::LazyLock;
2
3use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone};
4use doing_error::{Error, Result};
5use regex::Regex;
6
7use crate::parser::chronify;
8
9pub static RANGE_SEPARATOR_RE: LazyLock<Regex> =
10  LazyLock::new(|| Regex::new(r"(?i)\s+(?:to|through|thru|until|til|-{1,})\s+").unwrap());
11
12/// Parse a date range expression into a `(start, end)` tuple of `DateTime<Local>`.
13///
14/// Supports range separators: `to`, `through`, `thru`, `until`, `til`, and `--`/`-`.
15/// Each side of the range is parsed as a natural language date expression via [`chronify`].
16///
17/// When given a single date (no range separator), returns a 24-hour span from the
18/// start of that day to start of the next day.
19///
20/// # Examples
21///
22/// - `"monday to friday"`
23/// - `"yesterday to today"`
24/// - `"2024-01-01 through 2024-01-31"`
25/// - `"last monday to next friday"`
26/// - `"yesterday"` (returns yesterday 00:00:00 to today 00:00:00)
27/// - `"2024-01-15"` (returns 2024-01-15 00:00:00 to 2024-01-16 00:00:00)
28pub fn parse_range(input: &str) -> Result<(DateTime<Local>, DateTime<Local>)> {
29  let input = input.trim();
30
31  if input.is_empty() {
32    return Err(Error::InvalidTimeExpression("empty range input".into()));
33  }
34
35  let parts: Vec<&str> = RANGE_SEPARATOR_RE.splitn(input, 2).collect();
36
37  if parts.len() == 2 {
38    let start = chronify(parts[0])?;
39    let end = chronify(parts[1])?;
40    // When end is at midnight (date-only expression), extend to end-of-day to make inclusive
41    let end = if end.time() == NaiveTime::from_hms_opt(0, 0, 0).unwrap() {
42      end + Duration::days(1)
43    } else {
44      end
45    };
46    return Ok((start, end));
47  }
48
49  // Single date: return a 24-hour span from start-of-day to start-of-day + 24h
50  let parsed = chronify(input)?;
51  let naive_date = parsed.date_naive();
52  let start = Local
53    .from_local_datetime(&naive_date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()))
54    .single()
55    .ok_or_else(|| Error::InvalidTimeExpression(format!("ambiguous local time for: {input:?}")))?;
56  let end = start + Duration::days(1);
57
58  Ok((start, end))
59}
60
61#[cfg(test)]
62mod test {
63  use super::*;
64
65  mod parse_range {
66    use chrono::{Duration, NaiveDate, NaiveTime};
67    use pretty_assertions::assert_eq;
68
69    use super::*;
70
71    #[test]
72    fn it_parses_absolute_date_range() {
73      let (start, end) = parse_range("2024-01-01 to 2024-01-31").unwrap();
74
75      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
76      // End boundary is inclusive: 2024-01-31 midnight becomes 2024-02-01 midnight
77      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
78    }
79
80    #[test]
81    fn it_parses_combined_expressions() {
82      let (start, end) = parse_range("yesterday 3pm to today").unwrap();
83      let now = Local::now();
84
85      assert_eq!(start.date_naive(), (now - Duration::days(1)).date_naive());
86      assert_eq!(start.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
87      // "today" resolves to midnight, so end boundary becomes tomorrow midnight
88      assert_eq!(end.date_naive(), (now + Duration::days(1)).date_naive());
89    }
90
91    #[test]
92    fn it_parses_relative_range() {
93      let (start, end) = parse_range("yesterday to today").unwrap();
94      let now = Local::now();
95      let expected_start = (now - Duration::days(1)).date_naive();
96
97      assert_eq!(start.date_naive(), expected_start);
98      // End boundary is inclusive: today midnight becomes tomorrow midnight
99      assert_eq!(end.date_naive(), (now + Duration::days(1)).date_naive());
100    }
101
102    #[test]
103    fn it_rejects_empty_input() {
104      let err = parse_range("").unwrap_err();
105
106      assert!(matches!(err, Error::InvalidTimeExpression(_)));
107    }
108
109    #[test]
110    fn it_parses_single_absolute_date() {
111      let (start, end) = parse_range("2024-01-15").unwrap();
112
113      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
114      assert_eq!(start.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
115      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
116      assert_eq!(end.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
117    }
118
119    #[test]
120    fn it_parses_single_relative_date() {
121      let (start, end) = parse_range("yesterday").unwrap();
122      let now = Local::now();
123      let expected_date = (now - Duration::days(1)).date_naive();
124
125      assert_eq!(start.date_naive(), expected_date);
126      assert_eq!(start.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
127      assert_eq!(end, start + Duration::days(1));
128    }
129
130    #[test]
131    fn it_rejects_invalid_date_expressions() {
132      let err = parse_range("gibberish to nonsense").unwrap_err();
133
134      assert!(matches!(err, Error::InvalidTimeExpression(_)));
135    }
136
137    #[test]
138    fn it_rejects_invalid_single_date() {
139      let err = parse_range("gibberish").unwrap_err();
140
141      assert!(matches!(err, Error::InvalidTimeExpression(_)));
142    }
143
144    #[test]
145    fn it_supports_dash_separator() {
146      let (start, end) = parse_range("2024-01-01 -- 2024-01-31").unwrap();
147
148      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
149      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
150    }
151
152    #[test]
153    fn it_supports_through_separator() {
154      let (start, end) = parse_range("2024-01-01 through 2024-01-31").unwrap();
155
156      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
157      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
158    }
159
160    #[test]
161    fn it_supports_thru_separator() {
162      let (start, end) = parse_range("2024-01-01 thru 2024-01-31").unwrap();
163
164      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
165      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
166    }
167
168    #[test]
169    fn it_supports_until_separator() {
170      let (start, end) = parse_range("2024-01-01 until 2024-01-31").unwrap();
171
172      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
173      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
174    }
175  }
176}