use chrono::{DateTime, Datelike, Duration, NaiveTime, Utc, Weekday};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum VersionStatus {
#[default]
Quarantine,
Available,
Yanked,
Pinned,
}
impl std::fmt::Display for VersionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Quarantine => write!(f, "quarantine"),
Self::Available => write!(f, "available"),
Self::Yanked => write!(f, "yanked"),
Self::Pinned => write!(f, "pinned"),
}
}
}
impl std::str::FromStr for VersionStatus {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"quarantine" => Ok(Self::Quarantine),
"available" => Ok(Self::Available),
"yanked" => Ok(Self::Yanked),
"pinned" => Ok(Self::Pinned),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GemVersion {
pub id: i64,
pub name: String,
pub version: String,
pub platform: Option<String>,
pub sha256: Option<String>,
pub published_at: DateTime<Utc>,
pub available_after: DateTime<Utc>,
pub status: VersionStatus,
pub status_reason: Option<String>,
pub upstream_yanked: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuarantineInfo {
pub served_version: String,
pub requested_version: String,
pub available_after: DateTime<Utc>,
pub reason: String,
pub quarantined_versions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct QuarantineStats {
pub total_quarantined: u64,
pub total_available: u64,
pub total_yanked: u64,
pub total_pinned: u64,
pub versions_releasing_today: u64,
pub versions_releasing_this_week: u64,
}
#[derive(Debug, Clone)]
pub struct DelayPolicy {
pub default_delay_days: u32,
pub skip_weekends: bool,
pub business_hours_only: bool,
pub release_hour_utc: u8,
}
impl Default for DelayPolicy {
fn default() -> Self {
Self {
default_delay_days: 3,
skip_weekends: true,
business_hours_only: true,
release_hour_utc: 9,
}
}
}
pub fn calculate_availability(published: DateTime<Utc>, policy: &DelayPolicy) -> DateTime<Utc> {
let mut available = published + Duration::days(i64::from(policy.default_delay_days));
if policy.skip_weekends {
match available.weekday() {
Weekday::Sat => available += Duration::days(2),
Weekday::Sun => available += Duration::days(1),
_ => {}
}
}
if policy.business_hours_only {
if let Some(time) = NaiveTime::from_hms_opt(u32::from(policy.release_hour_utc), 0, 0) {
available = available.date_naive().and_time(time).and_utc();
}
}
available
}
pub fn is_version_available(gem_version: &GemVersion, now: DateTime<Utc>) -> bool {
match gem_version.status {
VersionStatus::Available | VersionStatus::Pinned => true,
VersionStatus::Yanked => false,
VersionStatus::Quarantine => now >= gem_version.available_after,
}
}
pub fn is_version_downloadable(gem_version: &GemVersion) -> bool {
gem_version.status != VersionStatus::Yanked
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Timelike};
#[test]
fn test_calculate_availability_basic() {
let published = Utc.with_ymd_and_hms(2025, 1, 6, 14, 0, 0).unwrap(); let policy = DelayPolicy {
default_delay_days: 3,
skip_weekends: false,
business_hours_only: false,
release_hour_utc: 9,
};
let available = calculate_availability(published, &policy);
assert_eq!(available.weekday(), Weekday::Thu);
}
#[test]
fn test_calculate_availability_skip_weekends() {
let published = Utc.with_ymd_and_hms(2025, 1, 9, 14, 0, 0).unwrap(); let policy = DelayPolicy {
default_delay_days: 3,
skip_weekends: true,
business_hours_only: false,
release_hour_utc: 9,
};
let available = calculate_availability(published, &policy);
assert_eq!(available.weekday(), Weekday::Mon);
}
#[test]
fn test_calculate_availability_business_hours() {
let published = Utc.with_ymd_and_hms(2025, 1, 6, 22, 0, 0).unwrap(); let policy = DelayPolicy {
default_delay_days: 3,
skip_weekends: false,
business_hours_only: true,
release_hour_utc: 9,
};
let available = calculate_availability(published, &policy);
assert_eq!(available.hour(), 9);
}
#[test]
fn test_is_version_available() {
let now = Utc::now();
let quarantined = GemVersion {
id: 1,
name: "test".to_string(),
version: "1.0.0".to_string(),
platform: None,
sha256: None,
published_at: now - Duration::days(1),
available_after: now + Duration::days(2),
status: VersionStatus::Quarantine,
status_reason: None,
upstream_yanked: false,
created_at: now,
updated_at: now,
};
assert!(!is_version_available(&quarantined, now));
let expired_quarantine = GemVersion {
available_after: now - Duration::hours(1),
..quarantined.clone()
};
assert!(is_version_available(&expired_quarantine, now));
let available = GemVersion {
status: VersionStatus::Available,
..quarantined.clone()
};
assert!(is_version_available(&available, now));
let pinned = GemVersion {
status: VersionStatus::Pinned,
..quarantined.clone()
};
assert!(is_version_available(&pinned, now));
let yanked = GemVersion {
status: VersionStatus::Yanked,
..quarantined.clone()
};
assert!(!is_version_available(&yanked, now));
}
#[test]
fn test_is_version_downloadable() {
let now = Utc::now();
let base = GemVersion {
id: 1,
name: "test".to_string(),
version: "1.0.0".to_string(),
platform: None,
sha256: None,
published_at: now,
available_after: now + Duration::days(3),
status: VersionStatus::Quarantine,
status_reason: None,
upstream_yanked: false,
created_at: now,
updated_at: now,
};
assert!(is_version_downloadable(&base));
let yanked = GemVersion {
status: VersionStatus::Yanked,
..base
};
assert!(!is_version_downloadable(&yanked));
}
#[test]
fn test_version_status_display() {
assert_eq!(VersionStatus::Quarantine.to_string(), "quarantine");
assert_eq!(VersionStatus::Available.to_string(), "available");
assert_eq!(VersionStatus::Yanked.to_string(), "yanked");
assert_eq!(VersionStatus::Pinned.to_string(), "pinned");
}
#[test]
fn test_version_status_from_str() {
assert_eq!(
"quarantine".parse::<VersionStatus>(),
Ok(VersionStatus::Quarantine)
);
assert_eq!(
"available".parse::<VersionStatus>(),
Ok(VersionStatus::Available)
);
assert_eq!("yanked".parse::<VersionStatus>(), Ok(VersionStatus::Yanked));
assert_eq!("pinned".parse::<VersionStatus>(), Ok(VersionStatus::Pinned));
assert!("invalid".parse::<VersionStatus>().is_err());
}
}