1use chrono::{DateTime, Datelike, Days, Duration, NaiveDate, NaiveDateTime, TimeZone, Utc};
9use chrono_tz::Tz;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Direction {
15 Past,
16 Future,
17}
18
19pub const MAX_RANGE_DAYS: u32 = 3650;
21pub const MAX_SPAN_DAYS: i64 = 3653;
23
24fn out_of_range(s: &str) -> String {
25 format!("date {s:?} is out of range")
26}
27
28fn shift(t: DateTime<Utc>, days: i64) -> Result<DateTime<Utc>, String> {
31 Duration::try_days(days)
32 .and_then(|d| t.checked_add_signed(d))
33 .ok_or_else(|| "that range reaches past the supported dates".to_string())
34}
35
36fn local_midnight(tz: Tz, date: NaiveDate) -> Result<DateTime<Utc>, String> {
37 tz.from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
38 .earliest()
39 .map(|d| d.with_timezone(&Utc))
40 .ok_or_else(|| {
41 format!(
42 "local midnight on {date} does not exist in {tz} (DST gap); use an explicit RFC 3339 time"
43 )
44 })
45}
46
47pub fn parse_point(s: &str, tz: Tz, end_of_day: bool) -> Result<DateTime<Utc>, String> {
51 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
52 return Ok(dt.with_timezone(&Utc));
53 }
54 let naive = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
55 .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"));
56 if let Ok(naive) = naive {
57 return tz
58 .from_local_datetime(&naive)
59 .earliest()
60 .map(|d| d.with_timezone(&Utc))
61 .ok_or_else(|| {
62 format!(
63 "local time {s} does not exist in {tz} (DST gap); use an explicit RFC 3339 time"
64 )
65 });
66 }
67 if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
68 let date = if end_of_day {
69 date.succ_opt().ok_or_else(|| out_of_range(s))?
70 } else {
71 date
72 };
73 return local_midnight(tz, date);
74 }
75 Err(format!(
76 "cannot parse date {s:?}; use YYYY-MM-DD or RFC 3339"
77 ))
78}
79
80pub fn parse_range(
96 range: Option<&str>,
97 from: Option<&str>,
98 to: Option<&str>,
99 tz: Tz,
100 now: DateTime<Utc>,
101 dir: Direction,
102) -> Result<(DateTime<Utc>, DateTime<Utc>), String> {
103 if from.is_some() || to.is_some() {
104 let a = from
105 .map(|s| parse_point(s, tz, false))
106 .transpose()?
107 .unwrap_or(now);
108 let b = match to {
109 Some(s) => parse_point(s, tz, true)?,
110 None => shift(a, 7)?,
111 };
112 if a >= b {
113 return Err("from must be before to".into());
114 }
115 if b - a > Duration::days(MAX_SPAN_DAYS) {
116 return Err("from and to may be at most 10 years apart".into());
117 }
118 return Ok((a, b));
119 }
120 let today = now.with_timezone(&tz).date_naive();
121 let monday = today
122 .checked_sub_days(Days::new(today.weekday().num_days_from_monday().into()))
123 .ok_or_else(|| out_of_range(&today.to_string()))?;
124 let days = |d: NaiveDate, n: u64| {
125 d.checked_add_days(Days::new(n))
126 .ok_or_else(|| out_of_range(&d.to_string()))
127 .and_then(|d| local_midnight(tz, d))
128 };
129 match range.unwrap_or("today") {
130 "today" => Ok((days(today, 0)?, days(today, 1)?)),
131 "tomorrow" => Ok((days(today, 1)?, days(today, 2)?)),
132 "this_week" => Ok((days(monday, 0)?, days(monday, 7)?)),
133 "next_week" => Ok((days(monday, 7)?, days(monday, 14)?)),
134 "next" => Ok((now, shift(now, 14)?)),
135 other => {
136 let n: u32 = other
137 .strip_suffix('d')
138 .and_then(|n| n.parse().ok())
139 .ok_or_else(|| {
140 format!(
141 "unknown range {other:?}; use today, tomorrow, this_week, next_week, next, Nd, or from/to"
142 )
143 })?;
144 if n > MAX_RANGE_DAYS {
145 return Err(format!(
146 "range {other:?} is too long; use at most {MAX_RANGE_DAYS}d"
147 ));
148 }
149 Ok(match dir {
150 Direction::Past => (shift(now, -i64::from(n))?, now),
151 Direction::Future => (now, shift(now, n.into())?),
152 })
153 }
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use chrono::TimeZone;
161
162 fn now() -> DateTime<Utc> {
163 chrono_tz::Europe::Stockholm
164 .with_ymd_and_hms(2026, 9, 23, 10, 0, 0)
165 .unwrap()
166 .with_timezone(&Utc)
167 }
168 fn tz() -> chrono_tz::Tz {
169 chrono_tz::Europe::Stockholm
170 }
171 fn local(y: i32, m: u32, d: u32, h: u32) -> DateTime<Utc> {
172 tz().with_ymd_and_hms(y, m, d, h, 0, 0)
173 .unwrap()
174 .with_timezone(&Utc)
175 }
176
177 #[test]
178 fn today_is_local_midnight_to_midnight() {
179 let (a, b) =
180 parse_range(Some("today"), None, None, tz(), now(), Direction::Future).unwrap();
181 assert_eq!((a, b), (local(2026, 9, 23, 0), local(2026, 9, 24, 0)));
182 }
183 #[test]
184 fn weeks_start_monday() {
185 let (a, b) = parse_range(
186 Some("this_week"),
187 None,
188 None,
189 tz(),
190 now(),
191 Direction::Future,
192 )
193 .unwrap();
194 assert_eq!((a, b), (local(2026, 9, 21, 0), local(2026, 9, 28, 0)));
195 let (a, b) = parse_range(
196 Some("next_week"),
197 None,
198 None,
199 tz(),
200 now(),
201 Direction::Future,
202 )
203 .unwrap();
204 assert_eq!((a, b), (local(2026, 9, 28, 0), local(2026, 10, 5, 0)));
205 }
206 #[test]
207 fn days_look_back_for_mail_and_forward_for_calendar() {
208 let (a, b) = parse_range(Some("3d"), None, None, tz(), now(), Direction::Past).unwrap();
209 assert_eq!((a, b), (now() - chrono::Duration::days(3), now()));
210 let (a, b) = parse_range(Some("3d"), None, None, tz(), now(), Direction::Future).unwrap();
211 assert_eq!((a, b), (now(), now() + chrono::Duration::days(3)));
212 }
213 #[test]
214 fn explicit_dates_are_local_and_inclusive_of_the_end_day() {
215 let (a, b) = parse_range(
216 None,
217 Some("2026-10-01"),
218 Some("2026-10-02"),
219 tz(),
220 now(),
221 Direction::Future,
222 )
223 .unwrap();
224 assert_eq!((a, b), (local(2026, 10, 1, 0), local(2026, 10, 3, 0)));
225 }
226 #[test]
227 fn day_ranges_are_capped_at_ten_years_without_panicking() {
228 for dir in [Direction::Past, Direction::Future] {
229 assert!(parse_range(Some("3650d"), None, None, tz(), now(), dir).is_ok());
230 let err = parse_range(Some("3651d"), None, None, tz(), now(), dir).unwrap_err();
231 assert!(err.contains("at most 3650d"), "{err}");
232 for huge in [
233 "4294967295d",
234 "99999999999999999999d",
235 "9223372036854775807d",
236 "-3d",
237 ] {
238 assert!(
239 parse_range(Some(huge), None, None, tz(), now(), dir).is_err(),
240 "{huge}"
241 );
242 }
243 }
244 }
245
246 #[test]
247 fn explicit_ranges_are_capped_and_calendar_edges_do_not_panic() {
248 let span = |from: Option<&str>, to: Option<&str>| {
249 parse_range(None, from, to, tz(), now(), Direction::Future)
250 };
251 assert!(span(Some("2026-01-01"), Some("2035-12-31")).is_ok());
252 let err = span(Some("2020-01-01"), Some("2031-01-01")).unwrap_err();
253 assert!(err.contains("10 years"), "{err}");
254 assert!(span(Some("2026-01-01"), Some("+262143-12-31")).is_err());
257 assert!(span(Some("+262143-12-31T12:00:00Z"), None).is_err());
258 assert!(span(Some("-262144-01-01"), Some("2026-01-01")).is_err());
259 assert!(parse_point("+262143-12-31", tz(), true).is_err());
260 }
261
262 #[test]
263 fn unknown_range_is_an_error() {
264 assert!(
265 parse_range(
266 Some("fortnight"),
267 None,
268 None,
269 tz(),
270 now(),
271 Direction::Future
272 )
273 .is_err()
274 );
275 }
276 #[test]
277 fn today_errors_instead_of_panicking_across_a_dst_gap() {
278 let dst_gap_day_tz = chrono_tz::America::Sao_Paulo;
282 let now_on_gap_day = dst_gap_day_tz
283 .with_ymd_and_hms(2018, 11, 4, 15, 0, 0)
284 .unwrap()
285 .with_timezone(&Utc);
286 let result = parse_range(
287 Some("today"),
288 None,
289 None,
290 dst_gap_day_tz,
291 now_on_gap_day,
292 Direction::Future,
293 );
294 assert!(result.is_err());
295 }
296 #[test]
297 fn parse_point_accepts_a_local_time_without_seconds() {
298 let tz = chrono_tz::Europe::Stockholm;
299 let expected = Utc.with_ymd_and_hms(2026, 9, 24, 12, 0, 0).unwrap();
300 assert_eq!(
301 parse_point("2026-09-24T14:00", tz, false).unwrap(),
302 expected
303 );
304 assert_eq!(
305 parse_point("2026-09-24T14:00", tz, false),
306 parse_point("2026-09-24T14:00:00", tz, false)
307 );
308 }
309
310 #[test]
311 fn parse_point_errors_on_a_nonexistent_local_time() {
312 let err =
313 parse_point("2018-11-04T00:30:00", chrono_tz::America::Sao_Paulo, false).unwrap_err();
314 assert!(
315 err.contains("does not exist"),
316 "expected 'does not exist' in error, got: {err}"
317 );
318 }
319}