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 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); }
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 assert_eq!(default_info.timestamp, 1_780_646_400);
226 assert_eq!(spcx_info.timestamp, 1_780_689_600);
227 }
228}