Skip to main content

pidge_core/
timerange.rs

1//! Time-range parsing in the user's local timezone.
2//!
3//! Mail ranges look backward from "now" (`Direction::Past`); calendar ranges look
4//! forward (`Direction::Future`). Named ranges (`today`, `this_week`, ...) and
5//! explicit `from`/`to` bounds are always resolved against the caller-supplied
6//! IANA timezone, then converted to UTC.
7
8use chrono::{DateTime, Datelike, Days, Duration, NaiveDate, NaiveDateTime, TimeZone, Utc};
9use chrono_tz::Tz;
10
11/// Whether a bare `Nd` range (e.g. `3d`) looks backward from `now` (mail) or
12/// forward from `now` (calendar).
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Direction {
15    Past,
16    Future,
17}
18
19/// Longest `Nd` range accepted: ten years of days.
20pub const MAX_RANGE_DAYS: u32 = 3650;
21/// Longest explicit `from`/`to` span accepted: ten years (with leap days).
22pub const MAX_SPAN_DAYS: i64 = 3653;
23
24fn out_of_range(s: &str) -> String {
25    format!("date {s:?} is out of range")
26}
27
28/// `t` moved by `days` (negative: back), or a usage error instead of the
29/// overflow panic `DateTime ± Duration` would raise.
30fn 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
47/// Parse a single timestamp: RFC 3339, a naive `YYYY-MM-DDTHH:MM:SS` or
48/// `YYYY-MM-DDTHH:MM` (interpreted in `tz`), or a bare `YYYY-MM-DD` date (interpreted as local midnight, or the
49/// following local midnight when `end_of_day` is set, making the date inclusive).
50pub 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
80/// Resolve a named range, an `Nd` range, or explicit `from`/`to` bounds into a
81/// `(start, end)` UTC pair.
82///
83/// - `today` / `tomorrow`: local midnight to the next local midnight.
84/// - `this_week` / `next_week`: Monday 00:00 local to the following Monday.
85/// - `next`: `now` to `now + 14 days` (callers take the first matching event).
86/// - `Nd` (e.g. `3d`, N at most [`MAX_RANGE_DAYS`]): `now - N days` to
87///   `now` when `dir` is `Past` (mail), or `now` to `now + N days` when
88///   `dir` is `Future` (calendar).
89/// - `from`/`to`: ISO 8601 date or datetime, naive values interpreted in `tz`;
90///   the end date is inclusive of its whole day. At most
91///   [`MAX_SPAN_DAYS`] apart.
92///
93/// Out-of-range input (huge `N`, dates at the edge of the calendar) is a
94/// usage error, never a panic.
95pub 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        // The last date chrono knows has no following midnight; a `from`
255        // there has no "a week later". Both are errors, not panics.
256        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        // America/Sao_Paulo sprang forward at 2018-11-04T00:00:00, so local
279        // midnight on that date does not exist. 15:00 local that same day is a
280        // valid, unambiguous moment we can use as `now`.
281        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}