Skip to main content

rapport_temporal/
offset.rs

1//! Relative date scheduling for follow-ups and deferrals
2
3use std::fmt::{Display, Formatter};
4
5use crate::{Error, date::Date, recurrence::Interval};
6use facet::Facet;
7use regex_macro::regex;
8use serde::{Deserialize, Serialize};
9
10/// A relative offset from a reference date, commonly used for follow-ups and scheduling
11// facet's derive emits an `unsafe impl`, which trips `unsafe_derive_deserialize`; deserialization itself is safe.
12#[allow(clippy::unsafe_derive_deserialize)]
13#[derive(Facet, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[repr(C)]
15pub enum RelativeOffset {
16    Tomorrow,
17    NextBusinessDay,
18    Days(Interval),
19    NextWeek,
20    InWeeks(Interval),
21    NextMonth,
22    InMonths(Interval),
23    NextYear,
24    InYears(Interval),
25}
26
27impl RelativeOffset {
28    /// The common options
29    #[must_use]
30    #[allow(unused)]
31    pub fn common_options() -> [Self; 11] {
32        [
33            Self::Tomorrow,
34            Self::NextBusinessDay,
35            Self::Days(Interval::two()),
36            Self::Days(Interval::three()),
37            Self::NextWeek,
38            Self::InWeeks(Interval::one()),
39            Self::InWeeks(Interval::two()),
40            Self::NextMonth,
41            Self::InMonths(Interval::two()),
42            Self::InMonths(Interval::three()),
43            Self::NextYear,
44        ]
45    }
46
47    /// Calculate the target date from the given reference date
48    #[must_use]
49    pub fn resolve_from(self, reference: Date) -> Date {
50        match self {
51            Self::Tomorrow => reference.add_days(1),
52            Self::Days(days) => reference.add_days(days.into()),
53            Self::NextBusinessDay => reference.next_business_day(),
54            Self::NextWeek => reference.next_monday(),
55            Self::InWeeks(weeks) => {
56                let weeks = usize::from(weeks);
57                let days = weeks.saturating_mul(7);
58                reference.add_days(days)
59            }
60            Self::NextMonth => reference.first_of_next_month(),
61            Self::InMonths(months) => reference.add_months(months),
62            Self::NextYear => reference.next_january_first(),
63            Self::InYears(years) => reference.add_years(years.into()),
64        }
65    }
66
67    /// Parse natural language expressions like "in 3 days", "next week"
68    ///
69    /// # Errors
70    /// when the given input does not parse into an offset
71    pub fn parse_natural(input: impl Into<String>) -> Result<Self, Error> {
72        let input = input.into().trim().to_lowercase();
73
74        match input.as_str() {
75            "tomorrow" => Ok(Self::Tomorrow),
76            "next business day" => Ok(Self::NextBusinessDay),
77            "next week" => Ok(Self::NextWeek),
78            "next month" => Ok(Self::NextMonth),
79            "next year" => Ok(Self::NextYear),
80            _ => {
81                // Parse "in N days/weeks/months/years" patterns
82                if let Some(captures) =
83                    regex!(r"^in (\d+) (days?|weeks?|months?|years?)$").captures(&input)
84                {
85                    let interval: u16 = captures[1]
86                        .parse()
87                        .map_err(|_| Error::InvalidOffset("Number too large".to_string()))?;
88                    let interval = Interval::try_from(interval).map_err(|_| {
89                        Error::InvalidOffset(format!("{interval} is an invalid offset"))
90                    })?;
91
92                    match &captures[2] {
93                        "day" | "days" => Ok(Self::Days(interval)),
94                        "week" | "weeks" => Ok(Self::InWeeks(interval)),
95                        "month" | "months" => Ok(Self::InMonths(interval)),
96                        "year" | "years" => Ok(Self::InYears(interval)),
97                        _ => Err(Error::InvalidOffset(format!(
98                            "Unknown time unit: {}",
99                            &captures[2]
100                        ))),
101                    }
102                } else {
103                    Err(Error::InvalidOffset(format!("Cannot parse: {input}")))
104                }
105            }
106        }
107    }
108}
109
110impl Display for RelativeOffset {
111    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
112        match self {
113            RelativeOffset::Tomorrow => f.write_str("tomorrow"),
114            RelativeOffset::NextBusinessDay => f.write_str("next business day"),
115            RelativeOffset::Days(interval) => {
116                if interval.is_many() {
117                    f.write_str(format!("in {interval} days").as_str())
118                } else {
119                    f.write_str("tomorrow")
120                }
121            }
122            RelativeOffset::NextWeek => f.write_str("next week"),
123            RelativeOffset::InWeeks(interval) => {
124                if interval.is_many() {
125                    f.write_str(format!("in {interval} weeks").as_str())
126                } else {
127                    f.write_str("in 1 week")
128                }
129            }
130            RelativeOffset::NextMonth => f.write_str("next month"),
131            RelativeOffset::InMonths(interval) => {
132                if interval.is_many() {
133                    f.write_str(format!("in {interval} months").as_str())
134                } else {
135                    f.write_str("next month")
136                }
137            }
138            RelativeOffset::NextYear => f.write_str("next year"),
139            RelativeOffset::InYears(interval) => {
140                if interval.is_many() {
141                    f.write_str(format!("in {interval} years").as_str())
142                } else {
143                    f.write_str("next year")
144                }
145            }
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use std::str::FromStr;
153
154    use super::*;
155    use claims::{assert_err, assert_ok};
156    use pretty_assertions::assert_eq;
157    use rstest::rstest;
158
159    #[rstest]
160    #[case::tomorrow(RelativeOffset::Tomorrow, "2025-01-03", "2025-01-04")]
161    #[case::two_days(RelativeOffset::Days(Interval::two()), "2025-01-03", "2025-01-05")]
162    #[case::three_days(RelativeOffset::Days(Interval::three()), "2025-01-03", "2025-01-06")]
163    #[case::next_business_day_from_friday_to_monday(
164        RelativeOffset::NextBusinessDay,
165        "2025-01-03",
166        "2025-01-06"
167    )]
168    #[case::next_business_day_monday_to_tuesday(
169        RelativeOffset::NextBusinessDay,
170        "2025-01-06",
171        "2025-01-07"
172    )]
173    #[case::next_week_friday_to_monday(RelativeOffset::NextWeek, "2025-01-03", "2025-01-06")]
174    #[case::next_week_monday_to_next_monday(RelativeOffset::NextWeek, "2025-01-06", "2025-01-13")]
175    #[case::one_week(RelativeOffset::InWeeks(Interval::one()), "2025-01-03", "2025-01-10")]
176    #[case::two_weeks(RelativeOffset::InWeeks(Interval::two()), "2025-01-03", "2025-01-17")]
177    #[case::next_month(RelativeOffset::NextMonth, "2025-01-15", "2025-02-01")]
178    #[case::one_month(RelativeOffset::InMonths(Interval::one()), "2025-01-15", "2025-02-15")]
179    #[case::three_months(
180        RelativeOffset::InMonths(Interval::three()),
181        "2025-01-15",
182        "2025-04-15"
183    )]
184    #[case::next_year(RelativeOffset::NextYear, "2025-06-15", "2026-01-01")]
185    #[case::one_year(RelativeOffset::InYears(Interval::one()), "2025-06-15", "2026-06-15")]
186    fn offset_resolve_from(
187        #[case] offset: RelativeOffset,
188        #[case] from: &str,
189        #[case] expected: &str,
190    ) {
191        let from = assert_ok!(Date::from_str(from), "precondition: from parses");
192        let expected = assert_ok!(Date::from_str(expected), "precondition: to parses");
193
194        let result = offset.resolve_from(from);
195        assert_eq!(result, expected);
196    }
197
198    #[rstest]
199    #[case::tomorrow("tomorrow", RelativeOffset::Tomorrow)]
200    #[case::next_business_day("next business day", RelativeOffset::NextBusinessDay)]
201    #[case::next_week("next week", RelativeOffset::NextWeek)]
202    #[case::next_month("next month", RelativeOffset::NextMonth)]
203    #[case::next_year("next year", RelativeOffset::NextYear)]
204    #[case::in_two_days("in 2 days", RelativeOffset::Days(Interval::two()))]
205    #[case::in_one_week("in 1 week", RelativeOffset::InWeeks(Interval::one()))]
206    #[case::in_three_months("in 3 months", RelativeOffset::InMonths(Interval::three()))]
207    #[case::in_one_year("in 1 year", RelativeOffset::InYears(Interval::one()))]
208    fn parse_natural_language(#[case] input: &str, #[case] expected: RelativeOffset) {
209        let result = assert_ok!(RelativeOffset::parse_natural(input));
210        assert_eq!(result, expected);
211    }
212
213    #[rstest]
214    #[case::invalid_format("next tuesday")]
215    #[case::unknown_unit("in 2 fortnights")]
216    #[case::empty_string("")]
217    #[case::malformed("in days 3")]
218    fn parse_natural_language_fails(#[case] input: &str) {
219        let result = RelativeOffset::parse_natural(input);
220        assert_err!(result);
221    }
222
223    #[rstest]
224    #[case::leap_year_handling(
225        "2024-02-29",
226        RelativeOffset::InYears(Interval::one()),
227        "2025-02-28"
228    )]
229    #[case::regular_year("2025-02-28", RelativeOffset::InYears(Interval::one()), "2026-02-28")]
230    #[case::month_end_edge_case(
231        "2025-01-31",
232        RelativeOffset::InMonths(Interval::one()),
233        "2025-02-28"
234    )]
235    fn edge_cases_handled_correctly(
236        #[case] from: &str,
237        #[case] offset: RelativeOffset,
238        #[case] expected: &str,
239    ) {
240        let from = assert_ok!(Date::from_str(from), "precondition: from a valid date");
241        let expected = assert_ok!(
242            Date::from_str(expected),
243            "precondition: expected a valid date"
244        );
245
246        let result = offset.resolve_from(from);
247        assert_eq!(result, expected);
248    }
249
250    #[rstest]
251    #[case::first(RelativeOffset::common_options()[0])]
252    #[case::second(RelativeOffset::common_options()[1])]
253    #[case::third(RelativeOffset::common_options()[2])]
254    #[case::fourth(RelativeOffset::common_options()[3])]
255    #[case::fifth(RelativeOffset::common_options()[4])]
256    #[case::sixth(RelativeOffset::common_options()[5])]
257    #[case::seventh(RelativeOffset::common_options()[6])]
258    #[case::eighth(RelativeOffset::common_options()[7])]
259    #[case::ninth(RelativeOffset::common_options()[8])]
260    #[case::tenth(RelativeOffset::common_options()[9])]
261    #[case::eleventh(RelativeOffset::common_options()[10])]
262    fn common_options_can_display_then_parse(#[case] option: RelativeOffset) {
263        let display = option.to_string();
264        let parsed = assert_ok!(
265            RelativeOffset::parse_natural(display.clone().as_str()),
266            "expecting offset to parse back from {display}"
267        );
268        assert_eq!(
269            parsed, option,
270            "expecting {display} to parse back to original option"
271        );
272    }
273
274    #[test]
275    fn common_options_include_one_week_and_next_week() {
276        let options = RelativeOffset::common_options();
277
278        assert!(options.contains(&RelativeOffset::InWeeks(Interval::one())));
279        assert!(options.contains(&RelativeOffset::NextWeek));
280    }
281
282    #[test]
283    fn one_week_and_next_week_resolve_differently_when_not_same_day() {
284        let today = assert_ok!(Date::from_str("2025-01-03"), "precondition: today parses");
285
286        assert_ne!(
287            RelativeOffset::InWeeks(Interval::one()).resolve_from(today),
288            RelativeOffset::NextWeek.resolve_from(today)
289        );
290    }
291
292    // Mirrors the consumer (app_v2) usage: `RelativeOffset` is carried inside a
293    // facet-0.44 enum that also derives serde + Hash/Eq and is `#[repr(C)]`. This
294    // compiles only if `RelativeOffset` (and the `Interval` it wraps) satisfy all
295    // of those bounds, which is the whole point of the derives.
296    #[test]
297    fn satisfies_facet_0_44_consumer_bounds() {
298        #[derive(
299            facet::Facet, serde::Serialize, serde::Deserialize, Hash, PartialEq, Eq, Clone, Debug,
300        )]
301        #[repr(C)]
302        #[allow(dead_code, clippy::unsafe_derive_deserialize)]
303        enum DateSelection {
304            Offset(RelativeOffset),
305            None,
306        }
307
308        fn assert_consumer_bounds<'a, T>()
309        where
310            T: facet::Facet<'a>
311                + serde::Serialize
312                + serde::de::DeserializeOwned
313                + std::hash::Hash
314                + Eq,
315        {
316        }
317
318        assert_consumer_bounds::<DateSelection>();
319        assert_consumer_bounds::<RelativeOffset>();
320        assert_consumer_bounds::<Interval>();
321    }
322}