1use std::fmt;
5
6use time::{Date, Duration, Month, OffsetDateTime, UtcOffset};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Retention {
11 Years(u32),
13 Months(u32),
15 Days(u32),
17}
18
19impl Retention {
20 #[must_use]
23 pub fn parse(text: &str) -> Option<Retention> {
24 let (digits, unit) = text.split_at_checked(text.len().checked_sub(1)?)?;
25 let leading_zero = digits.len() > 1 && digits.starts_with('0');
26 if leading_zero || !digits.bytes().all(|d| d.is_ascii_digit()) {
27 return None;
28 }
29 let n = digits.parse().ok()?;
30 match unit {
31 "y" => Some(Retention::Years(n)),
32 "m" => Some(Retention::Months(n)),
33 "d" => Some(Retention::Days(n)),
34 _ => None,
35 }
36 }
37
38 #[must_use]
41 pub fn after(self, at: OffsetDateTime) -> Option<OffsetDateTime> {
42 let at = at.to_offset(UtcOffset::UTC);
43 let months = match self {
44 Retention::Days(n) => return at.checked_add(Duration::days(i64::from(n))),
45 Retention::Years(n) => i64::from(n) * 12,
46 Retention::Months(n) => i64::from(n),
47 };
48 let total = i64::from(at.year()) * 12 + i64::from(u8::from(at.month())) - 1 + months;
49 let year = i32::try_from(total.div_euclid(12)).ok()?;
50 let month = Month::try_from(u8::try_from(total.rem_euclid(12) + 1).ok()?).ok()?;
51 let day = at.day().min(month.length(year));
52 Some(at.replace_date(Date::from_calendar_date(year, month, day).ok()?))
53 }
54}
55
56impl fmt::Display for Retention {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 match self {
59 Retention::Years(n) => write!(f, "{n}y"),
60 Retention::Months(n) => write!(f, "{n}m"),
61 Retention::Days(n) => write!(f, "{n}d"),
62 }
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use time::macros::datetime;
69
70 use super::*;
71
72 #[test]
73 fn a_period_is_added_by_the_calendar_and_clamps_to_the_months_end() {
74 let cases = [
75 (
76 datetime!(2026-10-23 12:00 UTC),
77 Retention::Years(7),
78 datetime!(2033-10-23 12:00 UTC),
79 ),
80 (
81 datetime!(2028-02-29 12:00 UTC),
82 Retention::Years(1),
83 datetime!(2029-02-28 12:00 UTC),
84 ),
85 (
86 datetime!(2028-02-29 12:00 UTC),
87 Retention::Years(4),
88 datetime!(2032-02-29 12:00 UTC),
89 ),
90 (
91 datetime!(2026-01-31 12:00 UTC),
92 Retention::Months(1),
93 datetime!(2026-02-28 12:00 UTC),
94 ),
95 (
96 datetime!(2028-01-31 12:00 UTC),
97 Retention::Months(1),
98 datetime!(2028-02-29 12:00 UTC),
99 ),
100 (
101 datetime!(2026-12-31 12:00 UTC),
102 Retention::Months(1),
103 datetime!(2027-01-31 12:00 UTC),
104 ),
105 (
106 datetime!(2026-11-15 12:00 UTC),
107 Retention::Months(18),
108 datetime!(2028-05-15 12:00 UTC),
109 ),
110 (
111 datetime!(2026-10-23 12:00 UTC),
112 Retention::Days(90),
113 datetime!(2027-01-21 12:00 UTC),
114 ),
115 (
116 datetime!(2028-02-28 12:00 UTC),
117 Retention::Days(1),
118 datetime!(2028-02-29 12:00 UTC),
119 ),
120 (
121 datetime!(2026-10-23 12:34:56.789 UTC),
122 Retention::Days(0),
123 datetime!(2026-10-23 12:34:56.789 UTC),
124 ),
125 (
126 datetime!(2026-10-23 12:34:56.789 UTC),
127 Retention::Months(0),
128 datetime!(2026-10-23 12:34:56.789 UTC),
129 ),
130 (
131 datetime!(2026-10-23 12:34:56.789 UTC),
132 Retention::Years(0),
133 datetime!(2026-10-23 12:34:56.789 UTC),
134 ),
135 (
136 datetime!(2026-05-31 23:59:59.999999999 UTC),
137 Retention::Months(1),
138 datetime!(2026-06-30 23:59:59.999999999 UTC),
139 ),
140 ];
141 for (at, period, due) in cases {
142 assert_eq!(period.after(at), Some(due), "{at} + {period:?}");
143 }
144 }
145
146 #[test]
150 fn a_period_is_counted_in_utc() {
151 let due = Retention::Months(1)
152 .after(datetime!(2026-03-31 02:00 +05:00))
153 .expect("due");
154 assert_eq!(
155 due,
156 datetime!(2026-04-30 21:00 UTC),
157 "02:00 +05:00 on 31 March is 21:00 UTC on 30 March, and a month on is 30 April"
158 );
159 assert!(due.offset().is_utc(), "the due time is given in UTC: {due}");
160 }
161
162 #[test]
163 fn a_period_past_the_last_time_this_build_holds_is_none() {
164 let late = datetime!(9999-12-31 23:59 UTC);
165 for period in [
166 Retention::Days(1),
167 Retention::Months(1),
168 Retention::Years(1),
169 ] {
170 assert_eq!(period.after(late), None, "{period:?}");
171 }
172 let now = datetime!(2026-10-23 12:00 UTC);
173 for period in [
174 Retention::Days(u32::MAX),
175 Retention::Months(u32::MAX),
176 Retention::Years(u32::MAX),
177 ] {
178 assert_eq!(period.after(now), None, "{period:?}");
179 }
180 assert_eq!(
181 Retention::Years(7973).after(now),
182 Some(datetime!(9999-10-23 12:00 UTC)),
183 "the last year still holds"
184 );
185 }
186
187 #[test]
188 fn a_period_reads_back_what_it_writes() {
189 for (text, period) in [
190 ("7y", Retention::Years(7)),
191 ("18m", Retention::Months(18)),
192 ("90d", Retention::Days(90)),
193 ("0d", Retention::Days(0)),
194 ("0m", Retention::Months(0)),
195 ("0y", Retention::Years(0)),
196 ("4294967295d", Retention::Days(u32::MAX)),
197 ] {
198 assert_eq!(Retention::parse(text), Some(period), "{text}");
199 assert_eq!(
200 period.to_string(),
201 text,
202 "Display writes the spelling parse reads"
203 );
204 }
205 }
206
207 #[test]
208 fn only_the_written_spelling_is_read() {
209 for text in [
210 "",
211 "y",
212 "7",
213 "7w",
214 "7Y",
215 "7 y",
216 " 7y",
217 "7y ",
218 "-1y",
219 "+1y",
220 "07y",
221 "00d",
222 "1.5y",
223 "7yd",
224 "1e3d",
225 "\u{0663}y",
226 "4294967296d",
227 "99999999999999999999y",
228 ] {
229 assert_eq!(Retention::parse(text), None, "{text:?}");
230 }
231 }
232}