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)\s+|\s+--+\s+|\s+-\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 a = chronify(parts[0])?;
39    let b = chronify(parts[1])?;
40    // Normalize reversed ranges before applying end-of-day extension
41    let (start, end) = if a > b { (b, a) } else { (a, b) };
42    // When end is at midnight (date-only expression), extend to end-of-day to make inclusive
43    let end = if end.time() == NaiveTime::from_hms_opt(0, 0, 0).unwrap() {
44      end + Duration::days(1)
45    } else {
46      end
47    };
48    return Ok((start, end));
49  }
50
51  // Single date: return a 24-hour span from start-of-day to start-of-day + 24h
52  let parsed = chronify(input)?;
53  let naive_date = parsed.date_naive();
54  let start = Local
55    .from_local_datetime(&naive_date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()))
56    .single()
57    .ok_or_else(|| Error::InvalidTimeExpression(format!("ambiguous local time for: {input:?}")))?;
58  let end = start + Duration::days(1);
59
60  Ok((start, end))
61}
62
63#[cfg(test)]
64mod test {
65  use super::*;
66
67  mod parse_range {
68    use chrono::{Duration, NaiveDate, NaiveTime};
69    use pretty_assertions::assert_eq;
70
71    use super::*;
72
73    #[test]
74    fn it_parses_absolute_date_range() {
75      let (start, end) = parse_range("2024-01-01 to 2024-01-31").unwrap();
76
77      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
78      // End boundary is inclusive: 2024-01-31 midnight becomes 2024-02-01 midnight
79      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
80    }
81
82    #[test]
83    fn it_parses_combined_expressions() {
84      let (start, end) = parse_range("yesterday 3pm to today").unwrap();
85      let now = Local::now();
86
87      assert_eq!(start.date_naive(), (now - Duration::days(1)).date_naive());
88      assert_eq!(start.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
89      // "today" resolves to midnight, so end boundary becomes tomorrow midnight
90      assert_eq!(end.date_naive(), (now + Duration::days(1)).date_naive());
91    }
92
93    #[test]
94    fn it_parses_relative_range() {
95      let (start, end) = parse_range("yesterday to today").unwrap();
96      let now = Local::now();
97      let expected_start = (now - Duration::days(1)).date_naive();
98
99      assert_eq!(start.date_naive(), expected_start);
100      // End boundary is inclusive: today midnight becomes tomorrow midnight
101      assert_eq!(end.date_naive(), (now + Duration::days(1)).date_naive());
102    }
103
104    #[test]
105    fn it_parses_single_absolute_date() {
106      let (start, end) = parse_range("2024-01-15").unwrap();
107
108      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
109      assert_eq!(start.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
110      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 16).unwrap());
111      assert_eq!(end.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
112    }
113
114    #[test]
115    fn it_parses_single_relative_date() {
116      let (start, end) = parse_range("yesterday").unwrap();
117      let now = Local::now();
118      let expected_date = (now - Duration::days(1)).date_naive();
119
120      assert_eq!(start.date_naive(), expected_date);
121      assert_eq!(start.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
122      assert_eq!(end, start + Duration::days(1));
123    }
124
125    #[test]
126    fn it_normalizes_reversed_range() {
127      let (start, end) = parse_range("2024-01-31 to 2024-01-01").unwrap();
128
129      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
130      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
131    }
132
133    #[test]
134    fn it_rejects_empty_input() {
135      let err = parse_range("").unwrap_err();
136
137      assert!(matches!(err, Error::InvalidTimeExpression(_)));
138    }
139
140    #[test]
141    fn it_rejects_invalid_date_expressions() {
142      let err = parse_range("gibberish to nonsense").unwrap_err();
143
144      assert!(matches!(err, Error::InvalidTimeExpression(_)));
145    }
146
147    #[test]
148    fn it_rejects_invalid_single_date() {
149      let err = parse_range("gibberish").unwrap_err();
150
151      assert!(matches!(err, Error::InvalidTimeExpression(_)));
152    }
153
154    #[test]
155    fn it_supports_dash_separator() {
156      let (start, end) = parse_range("2024-01-01 -- 2024-01-31").unwrap();
157
158      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
159      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
160    }
161
162    #[test]
163    fn it_supports_single_dash_with_iso_dates() {
164      let (start, end) = parse_range("2024-01-01 - 2024-01-31").unwrap();
165
166      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
167      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
168    }
169
170    #[test]
171    fn it_supports_through_separator() {
172      let (start, end) = parse_range("2024-01-01 through 2024-01-31").unwrap();
173
174      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
175      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
176    }
177
178    #[test]
179    fn it_supports_thru_separator() {
180      let (start, end) = parse_range("2024-01-01 thru 2024-01-31").unwrap();
181
182      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
183      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
184    }
185
186    #[test]
187    fn it_supports_until_separator() {
188      let (start, end) = parse_range("2024-01-01 until 2024-01-31").unwrap();
189
190      assert_eq!(start.date_naive(), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
191      assert_eq!(end.date_naive(), NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
192    }
193  }
194}