Skip to main content

catalog_manager/
expiry.rs

1use anyhow::{anyhow, Result};
2use chrono::{Datelike, Duration, NaiveDate, Utc, Weekday};
3use hypercall_types::{expiry_date_to_timestamp_at_time_checked, ExpiryTime};
4
5#[derive(Debug, Clone)]
6pub struct ExpirySchedule {
7    pub expiries: Vec<ExpiryInfo>,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct ExpiryInfo {
12    pub code: u32,
13    pub timestamp: i64,
14}
15
16impl ExpiryInfo {
17    pub fn from_date_time(date: NaiveDate, expiry_time: ExpiryTime) -> Result<Self> {
18        let code = date_to_code(date);
19        // Route through the shared hypercall-types conversion so the catalog
20        // and every downstream consumer derive identical timestamps.
21        let timestamp = expiry_date_to_timestamp_at_time_checked(u64::from(code), expiry_time)
22            .map_err(|e| anyhow!("Invalid expiry date {}: {}", code, e))?;
23        Ok(Self {
24            code,
25            timestamp: timestamp as i64,
26        })
27    }
28}
29
30pub fn generate_expiry_schedule(
31    daily_count: usize,
32    weekly_count: usize,
33    monthly_count: usize,
34    expiry_time: ExpiryTime,
35) -> Result<ExpirySchedule> {
36    generate_expiry_schedule_at_date(
37        Utc::now().date_naive(),
38        daily_count,
39        weekly_count,
40        monthly_count,
41        false,
42        expiry_time,
43    )
44}
45
46pub fn generate_expiry_schedule_at_date(
47    today: NaiveDate,
48    daily_count: usize,
49    weekly_count: usize,
50    monthly_count: usize,
51    weekdays_only: bool,
52    expiry_time: ExpiryTime,
53) -> Result<ExpirySchedule> {
54    let horizon = today
55        .checked_add_signed(Duration::days(365))
56        .ok_or_else(|| anyhow!("Expiry schedule horizon overflowed"))?;
57    let mut expiries = Vec::new();
58    let mut seen_codes = std::collections::HashSet::new();
59
60    let mut daily_count_found = 0;
61    let mut daily_offset = 0i64;
62    while daily_count_found < daily_count {
63        daily_offset += 1;
64        let date = today
65            .checked_add_signed(Duration::days(daily_offset))
66            .ok_or_else(|| anyhow!("Daily expiry date overflowed at offset {daily_offset}"))?;
67        if date > horizon {
68            break;
69        }
70        if weekdays_only && matches!(date.weekday(), Weekday::Sat | Weekday::Sun) {
71            continue;
72        }
73        let info = ExpiryInfo::from_date_time(date, expiry_time)?;
74        if seen_codes.insert(info.code) {
75            expiries.push(info);
76            daily_count_found += 1;
77        }
78    }
79
80    let mut friday_count = 0;
81    let mut current = today;
82    while friday_count < weekly_count {
83        current = current
84            .checked_add_signed(Duration::days(1))
85            .ok_or_else(|| anyhow!("Weekly expiry date overflowed"))?;
86        if current.weekday() == Weekday::Fri {
87            let info = ExpiryInfo::from_date_time(current, expiry_time)?;
88            if seen_codes.insert(info.code) {
89                expiries.push(info);
90                friday_count += 1;
91            }
92        }
93        if current > horizon {
94            break;
95        }
96    }
97
98    let mut monthly_count_found = 0;
99    let mut month_offset = 0i32;
100    while monthly_count_found < monthly_count && month_offset < 24 {
101        let target_month = today.month() as i32 + month_offset;
102        let target_year = today.year() + (target_month - 1) / 12;
103        let target_month = ((target_month - 1) % 12 + 1) as u32;
104
105        if let Some(last_friday) = last_friday_of_month(target_year, target_month) {
106            if last_friday > today {
107                let info = ExpiryInfo::from_date_time(last_friday, expiry_time)?;
108                if seen_codes.insert(info.code) {
109                    expiries.push(info);
110                    monthly_count_found += 1;
111                }
112            }
113        }
114        month_offset += 1;
115    }
116
117    expiries.sort_by_key(|e| e.timestamp);
118    Ok(ExpirySchedule { expiries })
119}
120
121fn last_friday_of_month(year: i32, month: u32) -> Option<NaiveDate> {
122    let next_month = if month == 12 { 1 } else { month + 1 };
123    let next_year = if month == 12 { year + 1 } else { year };
124    let first_of_next = NaiveDate::from_ymd_opt(next_year, next_month, 1)?;
125    let mut current = first_of_next.pred_opt()?;
126    while current.weekday() != Weekday::Fri {
127        current = current.pred_opt()?;
128    }
129    Some(current)
130}
131
132pub fn code_to_date(code: u32) -> Option<NaiveDate> {
133    let year = (code / 10000) as i32;
134    let month = (code / 100) % 100;
135    let day = code % 100;
136    NaiveDate::from_ymd_opt(year, month, day)
137}
138
139pub fn date_to_code(date: NaiveDate) -> u32 {
140    (date.year() as u32) * 10000 + date.month() * 100 + date.day()
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn expiry_info_from_date_uses_yyyymmdd() {
149        let date = NaiveDate::from_ymd_opt(2026, 1, 10).unwrap();
150        let info = ExpiryInfo::from_date_time(date, ExpiryTime { hour: 8, minute: 0 }).unwrap();
151        assert_eq!(info.code, 20260110);
152        assert_eq!(info.timestamp, 1_768_032_000); // 2026-01-10T08:00:00Z
153    }
154
155    #[test]
156    fn generated_schedule_is_future_sorted() {
157        let schedule =
158            generate_expiry_schedule(2, 2, 1, ExpiryTime { hour: 8, minute: 0 }).unwrap();
159        assert!(!schedule.expiries.is_empty());
160        let now = Utc::now().timestamp();
161        for exp in &schedule.expiries {
162            assert!(exp.timestamp > now);
163        }
164        for pair in schedule.expiries.windows(2) {
165            assert!(pair[0].timestamp <= pair[1].timestamp);
166        }
167    }
168
169    #[test]
170    fn weekday_daily_schedule_skips_weekends() {
171        let friday = NaiveDate::from_ymd_opt(2026, 6, 12).unwrap();
172        let schedule = generate_expiry_schedule_at_date(
173            friday,
174            3,
175            0,
176            0,
177            true,
178            ExpiryTime {
179                hour: 20,
180                minute: 0,
181            },
182        )
183        .unwrap();
184        let codes: Vec<u32> = schedule.expiries.iter().map(|expiry| expiry.code).collect();
185        assert_eq!(codes, vec![20260615, 20260616, 20260617]);
186        assert_eq!(schedule.expiries[0].timestamp, 1_781_553_600);
187    }
188
189    #[test]
190    fn calendar_daily_schedule_includes_weekends_and_weeklies() {
191        let friday = NaiveDate::from_ymd_opt(2026, 6, 12).unwrap();
192        let schedule = generate_expiry_schedule_at_date(
193            friday,
194            3,
195            4,
196            0,
197            false,
198            ExpiryTime {
199                hour: 20,
200                minute: 0,
201            },
202        )
203        .unwrap();
204        let codes: Vec<u32> = schedule.expiries.iter().map(|expiry| expiry.code).collect();
205        assert_eq!(
206            codes,
207            vec![20260613, 20260614, 20260615, 20260619, 20260626, 20260703, 20260710]
208        );
209    }
210
211    #[test]
212    fn per_underlying_expiry_time_shifts_timestamp() {
213        let date = NaiveDate::from_ymd_opt(2026, 6, 5).unwrap();
214        let default_info =
215            ExpiryInfo::from_date_time(date, ExpiryTime { hour: 8, minute: 0 }).unwrap();
216        let spcx_info = ExpiryInfo::from_date_time(
217            date,
218            ExpiryTime {
219                hour: 20,
220                minute: 0,
221            },
222        )
223        .unwrap();
224        // Parity with the retired spcx-4pm-et-expiry build flag.
225        assert_eq!(default_info.timestamp, 1_780_646_400);
226        assert_eq!(spcx_info.timestamp, 1_780_689_600);
227    }
228}