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