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, 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
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
30/// Parse a single timestamp: RFC 3339, a naive `YYYY-MM-DDTHH:MM:SS` or
31/// `YYYY-MM-DDTHH:MM` (interpreted in `tz`), or a bare `YYYY-MM-DD` date (interpreted as local midnight, or the
32/// following local midnight when `end_of_day` is set, making the date inclusive).
33pub 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
63/// Resolve a named range, an `Nd` range, or explicit `from`/`to` bounds into a
64/// `(start, end)` UTC pair.
65///
66/// - `today` / `tomorrow`: local midnight to the next local midnight.
67/// - `this_week` / `next_week`: Monday 00:00 local to the following Monday.
68/// - `next`: `now` to `now + 14 days` (callers take the first matching event).
69/// - `Nd` (e.g. `3d`): `now - N days` to `now` when `dir` is `Past` (mail), or
70///   `now` to `now + N days` when `dir` is `Future` (calendar).
71/// - `from`/`to`: ISO 8601 date or datetime, naive values interpreted in `tz`;
72///   the end date is inclusive of its whole day.
73pub 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        // America/Sao_Paulo sprang forward at 2018-11-04T00:00:00, so local
208        // midnight on that date does not exist. 15:00 local that same day is a
209        // valid, unambiguous moment we can use as `now`.
210        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}