1use chrono::{DateTime, Datelike, Duration, NaiveTime, Utc, Weekday};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "snake_case")]
13pub enum VersionStatus {
14 #[default]
16 Quarantine,
17 Available,
19 Yanked,
21 Pinned,
23}
24
25impl std::fmt::Display for VersionStatus {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 Self::Quarantine => write!(f, "quarantine"),
29 Self::Available => write!(f, "available"),
30 Self::Yanked => write!(f, "yanked"),
31 Self::Pinned => write!(f, "pinned"),
32 }
33 }
34}
35
36impl std::str::FromStr for VersionStatus {
37 type Err = ();
38
39 fn from_str(s: &str) -> Result<Self, Self::Err> {
40 match s {
41 "quarantine" => Ok(Self::Quarantine),
42 "available" => Ok(Self::Available),
43 "yanked" => Ok(Self::Yanked),
44 "pinned" => Ok(Self::Pinned),
45 _ => Err(()),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct GemVersion {
53 pub id: i64,
54 pub name: String,
55 pub version: String,
56 pub platform: Option<String>,
57 pub sha256: Option<String>,
58 pub published_at: DateTime<Utc>,
60 pub available_after: DateTime<Utc>,
62 pub status: VersionStatus,
63 pub status_reason: Option<String>,
65 pub upstream_yanked: bool,
67 pub created_at: DateTime<Utc>,
68 pub updated_at: DateTime<Utc>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct QuarantineInfo {
74 pub served_version: String,
75 pub requested_version: String,
76 pub available_after: DateTime<Utc>,
77 pub reason: String,
78 pub quarantined_versions: Vec<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
83pub struct QuarantineStats {
84 pub total_quarantined: u64,
85 pub total_available: u64,
86 pub total_yanked: u64,
87 pub total_pinned: u64,
88 pub versions_releasing_today: u64,
89 pub versions_releasing_this_week: u64,
90}
91
92#[derive(Debug, Clone)]
95pub struct DelayPolicy {
96 pub default_delay_days: u32,
97 pub skip_weekends: bool,
98 pub business_hours_only: bool,
99 pub release_hour_utc: u8,
100}
101
102impl Default for DelayPolicy {
103 fn default() -> Self {
104 Self {
105 default_delay_days: 3,
106 skip_weekends: true,
107 business_hours_only: true,
108 release_hour_utc: 9,
109 }
110 }
111}
112
113pub fn calculate_availability(published: DateTime<Utc>, policy: &DelayPolicy) -> DateTime<Utc> {
122 let mut available = published + Duration::days(i64::from(policy.default_delay_days));
123
124 if policy.skip_weekends {
125 match available.weekday() {
127 Weekday::Sat => available += Duration::days(2),
128 Weekday::Sun => available += Duration::days(1),
129 _ => {}
130 }
131 }
132
133 if policy.business_hours_only {
134 if let Some(time) = NaiveTime::from_hms_opt(u32::from(policy.release_hour_utc), 0, 0) {
136 available = available.date_naive().and_time(time).and_utc();
137 }
138 }
139
140 available
141}
142
143pub fn is_version_available(gem_version: &GemVersion, now: DateTime<Utc>) -> bool {
152 match gem_version.status {
153 VersionStatus::Available | VersionStatus::Pinned => true,
154 VersionStatus::Yanked => false,
155 VersionStatus::Quarantine => now >= gem_version.available_after,
156 }
157}
158
159pub fn is_version_downloadable(gem_version: &GemVersion) -> bool {
168 gem_version.status != VersionStatus::Yanked
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use chrono::{TimeZone, Timelike};
175
176 #[test]
177 fn test_calculate_availability_basic() {
178 let published = Utc.with_ymd_and_hms(2025, 1, 6, 14, 0, 0).unwrap(); let policy = DelayPolicy {
180 default_delay_days: 3,
181 skip_weekends: false,
182 business_hours_only: false,
183 release_hour_utc: 9,
184 };
185
186 let available = calculate_availability(published, &policy);
187 assert_eq!(available.weekday(), Weekday::Thu);
188 }
189
190 #[test]
191 fn test_calculate_availability_skip_weekends() {
192 let published = Utc.with_ymd_and_hms(2025, 1, 9, 14, 0, 0).unwrap(); let policy = DelayPolicy {
195 default_delay_days: 3,
196 skip_weekends: true,
197 business_hours_only: false,
198 release_hour_utc: 9,
199 };
200
201 let available = calculate_availability(published, &policy);
202 assert_eq!(available.weekday(), Weekday::Mon);
203 }
204
205 #[test]
206 fn test_calculate_availability_business_hours() {
207 let published = Utc.with_ymd_and_hms(2025, 1, 6, 22, 0, 0).unwrap(); let policy = DelayPolicy {
209 default_delay_days: 3,
210 skip_weekends: false,
211 business_hours_only: true,
212 release_hour_utc: 9,
213 };
214
215 let available = calculate_availability(published, &policy);
216 assert_eq!(available.hour(), 9);
217 }
218
219 #[test]
220 fn test_is_version_available() {
221 let now = Utc::now();
222
223 let quarantined = GemVersion {
225 id: 1,
226 name: "test".to_string(),
227 version: "1.0.0".to_string(),
228 platform: None,
229 sha256: None,
230 published_at: now - Duration::days(1),
231 available_after: now + Duration::days(2),
232 status: VersionStatus::Quarantine,
233 status_reason: None,
234 upstream_yanked: false,
235 created_at: now,
236 updated_at: now,
237 };
238 assert!(!is_version_available(&quarantined, now));
239
240 let expired_quarantine = GemVersion {
242 available_after: now - Duration::hours(1),
243 ..quarantined.clone()
244 };
245 assert!(is_version_available(&expired_quarantine, now));
246
247 let available = GemVersion {
249 status: VersionStatus::Available,
250 ..quarantined.clone()
251 };
252 assert!(is_version_available(&available, now));
253
254 let pinned = GemVersion {
256 status: VersionStatus::Pinned,
257 ..quarantined.clone()
258 };
259 assert!(is_version_available(&pinned, now));
260
261 let yanked = GemVersion {
263 status: VersionStatus::Yanked,
264 ..quarantined.clone()
265 };
266 assert!(!is_version_available(&yanked, now));
267 }
268
269 #[test]
270 fn test_is_version_downloadable() {
271 let now = Utc::now();
272
273 let base = GemVersion {
274 id: 1,
275 name: "test".to_string(),
276 version: "1.0.0".to_string(),
277 platform: None,
278 sha256: None,
279 published_at: now,
280 available_after: now + Duration::days(3),
281 status: VersionStatus::Quarantine,
282 status_reason: None,
283 upstream_yanked: false,
284 created_at: now,
285 updated_at: now,
286 };
287
288 assert!(is_version_downloadable(&base));
290
291 let yanked = GemVersion {
293 status: VersionStatus::Yanked,
294 ..base
295 };
296 assert!(!is_version_downloadable(&yanked));
297 }
298
299 #[test]
300 fn test_version_status_display() {
301 assert_eq!(VersionStatus::Quarantine.to_string(), "quarantine");
302 assert_eq!(VersionStatus::Available.to_string(), "available");
303 assert_eq!(VersionStatus::Yanked.to_string(), "yanked");
304 assert_eq!(VersionStatus::Pinned.to_string(), "pinned");
305 }
306
307 #[test]
308 fn test_version_status_from_str() {
309 assert_eq!(
310 "quarantine".parse::<VersionStatus>(),
311 Ok(VersionStatus::Quarantine)
312 );
313 assert_eq!(
314 "available".parse::<VersionStatus>(),
315 Ok(VersionStatus::Available)
316 );
317 assert_eq!("yanked".parse::<VersionStatus>(), Ok(VersionStatus::Yanked));
318 assert_eq!("pinned".parse::<VersionStatus>(), Ok(VersionStatus::Pinned));
319 assert!("invalid".parse::<VersionStatus>().is_err());
320 }
321}