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