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    Years(u32),
12    Months(u32),
13    Days(u32),
14}
15
16impl Retention {
17    /// Reads a period written by `Display`: decimal digits with no sign and
18    /// no leading zero, then `y`, `m` or `d`.
19    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    /// The time this period after `at`, in UTC, or None if that is past
35    /// the last time this build can hold.
36    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    /// 2026-03-31 02:00 at +05:00 is 2026-03-30 21:00 UTC. A month later in
142    /// UTC is 2026-04-30 21:00; counted at +05:00 it would be 2026-04-29
143    /// 21:00 UTC.
144    #[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}