Skip to main content

vcon_server/
retention.rs

1//! A retention period: a calendar period of whole years, months or days,
2//! added to a time in UTC.
3
4use std::fmt;
5
6use time::{Date, Duration, Month, OffsetDateTime, UtcOffset};
7
8/// How long a version is kept after it is stored.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Retention {
11    /// This many calendar years, written `7y`.
12    Years(u32),
13    /// This many calendar months, written `18m`.
14    Months(u32),
15    /// This many days of 24 hours, written `90d`.
16    Days(u32),
17}
18
19impl Retention {
20    /// Reads a period written by `Display`: decimal digits with no sign and
21    /// no leading zero, then `y`, `m` or `d`.
22    #[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    /// The time this period after `at`, in UTC, or None if that is past
39    /// the last time this build can hold.
40    #[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    /// 2026-03-31 02:00 at +05:00 is 2026-03-30 21:00 UTC. A month later in
147    /// UTC is 2026-04-30 21:00; counted at +05:00 it would be 2026-04-29
148    /// 21:00 UTC.
149    #[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}