1use std::fmt::{Display, Formatter};
4
5use crate::{Error, date::Date, recurrence::Interval};
6use regex_macro::regex;
7
8#[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 #[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 #[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 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 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}