use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
pub type ReleaseId = u64;
pub type AssetId = u64;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Release {
pub id: ReleaseId,
pub repo_key: String,
pub tag_name: String,
pub target_commitish: String,
pub name: Option<String>,
pub body: Option<String>,
pub draft: bool,
pub prerelease: bool,
pub author: String,
pub assets: Vec<ReleaseAsset>,
pub created_at: u64,
pub published_at: Option<u64>,
}
impl Release {
pub fn new(
id: ReleaseId,
repo_key: String,
tag_name: String,
target_commitish: String,
author: String,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Self {
id,
repo_key,
tag_name,
target_commitish,
name: None,
body: None,
draft: false,
prerelease: false,
author,
assets: Vec::new(),
created_at: now,
published_at: Some(now),
}
}
pub fn is_publishable(&self) -> bool {
!self.draft && !self.prerelease
}
pub fn publish(&mut self) {
self.draft = false;
self.published_at = Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
);
}
pub fn add_asset(&mut self, asset: ReleaseAsset) {
self.assets.push(asset);
}
pub fn remove_asset(&mut self, asset_id: AssetId) -> Option<ReleaseAsset> {
if let Some(pos) = self.assets.iter().position(|a| a.id == asset_id) {
Some(self.assets.remove(pos))
} else {
None
}
}
pub fn to_response(&self) -> ReleaseResponse {
ReleaseResponse {
id: self.id,
tag_name: self.tag_name.clone(),
target_commitish: self.target_commitish.clone(),
name: self.name.clone(),
body: self.body.clone(),
draft: self.draft,
prerelease: self.prerelease,
author: AuthorInfo {
login: self.author.clone(),
},
assets: self.assets.iter().map(|a| a.to_response()).collect(),
created_at: format_timestamp(self.created_at),
published_at: self.published_at.map(format_timestamp),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseAsset {
pub id: AssetId,
pub release_id: ReleaseId,
pub name: String,
pub label: Option<String>,
pub content_type: String,
pub size: u64,
pub download_count: u64,
pub content_hash: String,
pub created_at: u64,
pub uploader: String,
}
impl ReleaseAsset {
pub fn new(
id: AssetId,
release_id: ReleaseId,
name: String,
content_type: String,
size: u64,
content_hash: String,
uploader: String,
) -> Self {
Self {
id,
release_id,
name,
label: None,
content_type,
size,
download_count: 0,
content_hash,
created_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
uploader,
}
}
pub fn increment_downloads(&mut self) {
self.download_count += 1;
}
pub fn to_response(&self) -> AssetResponse {
AssetResponse {
id: self.id,
name: self.name.clone(),
label: self.label.clone(),
content_type: self.content_type.clone(),
size: self.size,
download_count: self.download_count,
created_at: format_timestamp(self.created_at),
uploader: AuthorInfo {
login: self.uploader.clone(),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorInfo {
pub login: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseResponse {
pub id: ReleaseId,
pub tag_name: String,
pub target_commitish: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
pub draft: bool,
pub prerelease: bool,
pub author: AuthorInfo,
pub assets: Vec<AssetResponse>,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetResponse {
pub id: AssetId,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub content_type: String,
pub size: u64,
pub download_count: u64,
pub created_at: String,
pub uploader: AuthorInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateReleaseRequest {
pub tag_name: String,
#[serde(default)]
pub target_commitish: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub prerelease: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateReleaseRequest {
#[serde(default)]
pub tag_name: Option<String>,
#[serde(default)]
pub target_commitish: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub draft: Option<bool>,
#[serde(default)]
pub prerelease: Option<bool>,
}
fn format_timestamp(timestamp: u64) -> String {
let secs_per_day = 86400;
let secs_per_hour = 3600;
let secs_per_min = 60;
let mut days = timestamp / secs_per_day;
let remaining = timestamp % secs_per_day;
let hours = remaining / secs_per_hour;
let remaining = remaining % secs_per_hour;
let minutes = remaining / secs_per_min;
let seconds = remaining % secs_per_min;
let mut year = 1970;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let days_in_month = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 0;
for (i, &dim) in days_in_month.iter().enumerate() {
if days < dim as u64 {
month = i + 1;
break;
}
days -= dim as u64;
}
let day = days + 1;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn is_leap_year(year: u64) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_release_creation() {
let release = Release::new(
1,
"alice/repo".to_string(),
"v1.0.0".to_string(),
"main".to_string(),
"alice".to_string(),
);
assert_eq!(release.id, 1);
assert_eq!(release.tag_name, "v1.0.0");
assert!(!release.draft);
assert!(!release.prerelease);
assert!(release.published_at.is_some());
}
#[test]
fn test_draft_release() {
let mut release = Release::new(
1,
"alice/repo".to_string(),
"v1.0.0".to_string(),
"main".to_string(),
"alice".to_string(),
);
release.draft = true;
release.published_at = None;
assert!(!release.is_publishable());
release.publish();
assert!(release.is_publishable());
assert!(release.published_at.is_some());
}
#[test]
fn test_asset_management() {
let mut release = Release::new(
1,
"alice/repo".to_string(),
"v1.0.0".to_string(),
"main".to_string(),
"alice".to_string(),
);
let asset = ReleaseAsset::new(
1,
1,
"app-v1.0.0.tar.gz".to_string(),
"application/gzip".to_string(),
1024,
"abc123".to_string(),
"alice".to_string(),
);
release.add_asset(asset);
assert_eq!(release.assets.len(), 1);
let removed = release.remove_asset(1);
assert!(removed.is_some());
assert_eq!(release.assets.len(), 0);
}
#[test]
fn test_asset_downloads() {
let mut asset = ReleaseAsset::new(
1,
1,
"app.tar.gz".to_string(),
"application/gzip".to_string(),
1024,
"abc123".to_string(),
"alice".to_string(),
);
assert_eq!(asset.download_count, 0);
asset.increment_downloads();
asset.increment_downloads();
assert_eq!(asset.download_count, 2);
}
}