1use 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#[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 #[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 #[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 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 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 #[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}