1use chrono::{DateTime, Datelike, 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
19fn local_midnight(tz: Tz, date: NaiveDate) -> Result<DateTime<Utc>, String> {
20 tz.from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
21 .earliest()
22 .map(|d| d.with_timezone(&Utc))
23 .ok_or_else(|| {
24 format!(
25 "local midnight on {date} does not exist in {tz} (DST gap); use an explicit RFC 3339 time"
26 )
27 })
28}
29
30pub fn parse_point(s: &str, tz: Tz, end_of_day: bool) -> Result<DateTime<Utc>, String> {
34 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
35 return Ok(dt.with_timezone(&Utc));
36 }
37 let naive = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
38 .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"));
39 if let Ok(naive) = naive {
40 return tz
41 .from_local_datetime(&naive)
42 .earliest()
43 .map(|d| d.with_timezone(&Utc))
44 .ok_or_else(|| {
45 format!(
46 "local time {s} does not exist in {tz} (DST gap); use an explicit RFC 3339 time"
47 )
48 });
49 }
50 if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
51 let date = if end_of_day {
52 date + Duration::days(1)
53 } else {
54 date
55 };
56 return local_midnight(tz, date);
57 }
58 Err(format!(
59 "cannot parse date {s:?}; use YYYY-MM-DD or RFC 3339"
60 ))
61}
62
63pub fn parse_range(
74 range: Option<&str>,
75 from: Option<&str>,
76 to: Option<&str>,
77 tz: Tz,
78 now: DateTime<Utc>,
79 dir: Direction,
80) -> Result<(DateTime<Utc>, DateTime<Utc>), String> {
81 if from.is_some() || to.is_some() {
82 let a = from
83 .map(|s| parse_point(s, tz, false))
84 .transpose()?
85 .unwrap_or(now);
86 let b = to
87 .map(|s| parse_point(s, tz, true))
88 .transpose()?
89 .unwrap_or(a + Duration::days(7));
90 return if a < b {
91 Ok((a, b))
92 } else {
93 Err("from must be before to".into())
94 };
95 }
96 let today = now.with_timezone(&tz).date_naive();
97 let monday = today - Duration::days(today.weekday().num_days_from_monday() as i64);
98 let days = |d: NaiveDate, n: i64| local_midnight(tz, d + Duration::days(n));
99 match range.unwrap_or("today") {
100 "today" => Ok((days(today, 0)?, days(today, 1)?)),
101 "tomorrow" => Ok((days(today, 1)?, days(today, 2)?)),
102 "this_week" => Ok((days(monday, 0)?, days(monday, 7)?)),
103 "next_week" => Ok((days(monday, 7)?, days(monday, 14)?)),
104 "next" => Ok((now, now + Duration::days(14))),
105 other => {
106 let n: i64 = other
107 .strip_suffix('d')
108 .and_then(|n| n.parse().ok())
109 .ok_or_else(|| {
110 format!(
111 "unknown range {other:?}; use today, tomorrow, this_week, next_week, next, Nd, or from/to"
112 )
113 })?;
114 Ok(match dir {
115 Direction::Past => (now - Duration::days(n), now),
116 Direction::Future => (now, now + Duration::days(n)),
117 })
118 }
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use chrono::TimeZone;
126
127 fn now() -> DateTime<Utc> {
128 chrono_tz::Europe::Stockholm
129 .with_ymd_and_hms(2026, 9, 23, 10, 0, 0)
130 .unwrap()
131 .with_timezone(&Utc)
132 }
133 fn tz() -> chrono_tz::Tz {
134 chrono_tz::Europe::Stockholm
135 }
136 fn local(y: i32, m: u32, d: u32, h: u32) -> DateTime<Utc> {
137 tz().with_ymd_and_hms(y, m, d, h, 0, 0)
138 .unwrap()
139 .with_timezone(&Utc)
140 }
141
142 #[test]
143 fn today_is_local_midnight_to_midnight() {
144 let (a, b) =
145 parse_range(Some("today"), None, None, tz(), now(), Direction::Future).unwrap();
146 assert_eq!((a, b), (local(2026, 9, 23, 0), local(2026, 9, 24, 0)));
147 }
148 #[test]
149 fn weeks_start_monday() {
150 let (a, b) = parse_range(
151 Some("this_week"),
152 None,
153 None,
154 tz(),
155 now(),
156 Direction::Future,
157 )
158 .unwrap();
159 assert_eq!((a, b), (local(2026, 9, 21, 0), local(2026, 9, 28, 0)));
160 let (a, b) = parse_range(
161 Some("next_week"),
162 None,
163 None,
164 tz(),
165 now(),
166 Direction::Future,
167 )
168 .unwrap();
169 assert_eq!((a, b), (local(2026, 9, 28, 0), local(2026, 10, 5, 0)));
170 }
171 #[test]
172 fn days_look_back_for_mail_and_forward_for_calendar() {
173 let (a, b) = parse_range(Some("3d"), None, None, tz(), now(), Direction::Past).unwrap();
174 assert_eq!((a, b), (now() - chrono::Duration::days(3), now()));
175 let (a, b) = parse_range(Some("3d"), None, None, tz(), now(), Direction::Future).unwrap();
176 assert_eq!((a, b), (now(), now() + chrono::Duration::days(3)));
177 }
178 #[test]
179 fn explicit_dates_are_local_and_inclusive_of_the_end_day() {
180 let (a, b) = parse_range(
181 None,
182 Some("2026-10-01"),
183 Some("2026-10-02"),
184 tz(),
185 now(),
186 Direction::Future,
187 )
188 .unwrap();
189 assert_eq!((a, b), (local(2026, 10, 1, 0), local(2026, 10, 3, 0)));
190 }
191 #[test]
192 fn unknown_range_is_an_error() {
193 assert!(
194 parse_range(
195 Some("fortnight"),
196 None,
197 None,
198 tz(),
199 now(),
200 Direction::Future
201 )
202 .is_err()
203 );
204 }
205 #[test]
206 fn today_errors_instead_of_panicking_across_a_dst_gap() {
207 let dst_gap_day_tz = chrono_tz::America::Sao_Paulo;
211 let now_on_gap_day = dst_gap_day_tz
212 .with_ymd_and_hms(2018, 11, 4, 15, 0, 0)
213 .unwrap()
214 .with_timezone(&Utc);
215 let result = parse_range(
216 Some("today"),
217 None,
218 None,
219 dst_gap_day_tz,
220 now_on_gap_day,
221 Direction::Future,
222 );
223 assert!(result.is_err());
224 }
225 #[test]
226 fn parse_point_accepts_a_local_time_without_seconds() {
227 let tz = chrono_tz::Europe::Stockholm;
228 let expected = Utc.with_ymd_and_hms(2026, 9, 24, 12, 0, 0).unwrap();
229 assert_eq!(
230 parse_point("2026-09-24T14:00", tz, false).unwrap(),
231 expected
232 );
233 assert_eq!(
234 parse_point("2026-09-24T14:00", tz, false),
235 parse_point("2026-09-24T14:00:00", tz, false)
236 );
237 }
238
239 #[test]
240 fn parse_point_errors_on_a_nonexistent_local_time() {
241 let err =
242 parse_point("2018-11-04T00:30:00", chrono_tz::America::Sao_Paulo, false).unwrap_err();
243 assert!(
244 err.contains("does not exist"),
245 "expected 'does not exist' in error, got: {err}"
246 );
247 }
248}