hypercall_sdk_types/
expiry_times.rs1use std::collections::HashMap;
20use std::sync::OnceLock;
21
22pub const DEFAULT_EXPIRY_TIME: ExpiryTime = ExpiryTime { hour: 8, minute: 0 };
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct ExpiryTime {
28 pub hour: u32,
30 pub minute: u32,
32}
33
34impl ExpiryTime {
35 pub fn new(hour: u32, minute: u32) -> Result<Self, ExpiryTimeParseError> {
37 if hour > 23 || minute > 59 {
38 return Err(ExpiryTimeParseError::OutOfRange { hour, minute });
39 }
40 Ok(Self { hour, minute })
41 }
42
43 pub fn parse(value: &str) -> Result<Self, ExpiryTimeParseError> {
45 let (hour_str, minute_str) =
46 value
47 .split_once(':')
48 .ok_or_else(|| ExpiryTimeParseError::InvalidFormat {
49 value: value.to_string(),
50 })?;
51 let hour: u32 = hour_str
52 .parse()
53 .map_err(|_| ExpiryTimeParseError::InvalidFormat {
54 value: value.to_string(),
55 })?;
56 let minute: u32 = minute_str
57 .parse()
58 .map_err(|_| ExpiryTimeParseError::InvalidFormat {
59 value: value.to_string(),
60 })?;
61 Self::new(hour, minute)
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum ExpiryTimeParseError {
68 InvalidFormat { value: String },
70 OutOfRange { hour: u32, minute: u32 },
72}
73
74impl std::fmt::Display for ExpiryTimeParseError {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 match self {
77 Self::InvalidFormat { value } => {
78 write!(f, "Invalid expiry time '{}': expected HH:MM", value)
79 }
80 Self::OutOfRange { hour, minute } => {
81 write!(
82 f,
83 "Invalid expiry time {}:{:02}: out of range",
84 hour, minute
85 )
86 }
87 }
88 }
89}
90
91impl std::error::Error for ExpiryTimeParseError {}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ExpiryTimes {
96 default: ExpiryTime,
97 overrides: HashMap<String, ExpiryTime>,
98}
99
100impl Default for ExpiryTimes {
101 fn default() -> Self {
102 Self {
103 default: DEFAULT_EXPIRY_TIME,
104 overrides: HashMap::new(),
105 }
106 }
107}
108
109impl ExpiryTimes {
110 pub fn new(default: ExpiryTime, overrides: HashMap<String, ExpiryTime>) -> Self {
112 Self { default, overrides }
113 }
114
115 pub fn for_underlying(&self, underlying: &str) -> ExpiryTime {
117 self.overrides
118 .get(underlying)
119 .copied()
120 .unwrap_or(self.default)
121 }
122}
123
124static EXPIRY_TIMES: OnceLock<ExpiryTimes> = OnceLock::new();
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ExpiryTimesInstallError {
129 pub existing: ExpiryTimes,
131}
132
133impl std::fmt::Display for ExpiryTimesInstallError {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 write!(
136 f,
137 "Expiry times already installed with a different configuration: {:?}",
138 self.existing
139 )
140 }
141}
142
143impl std::error::Error for ExpiryTimesInstallError {}
144
145pub fn install_expiry_times(times: ExpiryTimes) -> Result<(), ExpiryTimesInstallError> {
152 let installed = EXPIRY_TIMES.get_or_init(|| times.clone());
153 if *installed == times {
154 Ok(())
155 } else {
156 Err(ExpiryTimesInstallError {
157 existing: installed.clone(),
158 })
159 }
160}
161
162pub fn expiry_times() -> &'static ExpiryTimes {
165 static DEFAULT: OnceLock<ExpiryTimes> = OnceLock::new();
166 EXPIRY_TIMES.get().unwrap_or_else(|| {
167 DEFAULT.get_or_init(|| {
168 tracing::warn!("ExpiryTimes not installed; using all-defaults policy (08:00 UTC)");
172 ExpiryTimes::default()
173 })
174 })
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn parse_accepts_hh_mm() {
183 assert_eq!(
184 ExpiryTime::parse("08:00").unwrap(),
185 ExpiryTime { hour: 8, minute: 0 }
186 );
187 assert_eq!(
188 ExpiryTime::parse("20:00").unwrap(),
189 ExpiryTime {
190 hour: 20,
191 minute: 0
192 }
193 );
194 assert_eq!(
195 ExpiryTime::parse("9:30").unwrap(),
196 ExpiryTime {
197 hour: 9,
198 minute: 30
199 }
200 );
201 }
202
203 #[test]
204 fn parse_rejects_invalid_input() {
205 assert!(ExpiryTime::parse("8").is_err());
206 assert!(ExpiryTime::parse("08:00:00").is_err());
207 assert!(ExpiryTime::parse("24:00").is_err());
208 assert!(ExpiryTime::parse("08:60").is_err());
209 assert!(ExpiryTime::parse("ab:cd").is_err());
210 assert!(ExpiryTime::parse("").is_err());
211 }
212
213 #[test]
214 fn overrides_resolve_per_underlying() {
215 let times = ExpiryTimes::new(
216 DEFAULT_EXPIRY_TIME,
217 HashMap::from([(
218 "SPCX".to_string(),
219 ExpiryTime {
220 hour: 20,
221 minute: 0,
222 },
223 )]),
224 );
225 assert_eq!(
226 times.for_underlying("SPCX"),
227 ExpiryTime {
228 hour: 20,
229 minute: 0
230 }
231 );
232 assert_eq!(times.for_underlying("BTC"), DEFAULT_EXPIRY_TIME);
233 }
234
235 #[test]
236 fn uninstalled_policy_defaults_to_0800_utc() {
237 assert_eq!(
240 ExpiryTimes::default().for_underlying("BTC"),
241 DEFAULT_EXPIRY_TIME
242 );
243 }
244}