1use serde::{Deserialize, Serialize};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6pub type ReleaseId = u64;
8
9pub type AssetId = u64;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Release {
15 pub id: ReleaseId,
17 pub repo_key: String,
19 pub tag_name: String,
21 pub target_commitish: String,
23 pub name: Option<String>,
25 pub body: Option<String>,
27 pub draft: bool,
29 pub prerelease: bool,
31 pub author: String,
33 pub assets: Vec<ReleaseAsset>,
35 pub created_at: u64,
37 pub published_at: Option<u64>,
39}
40
41impl Release {
42 pub fn new(
44 id: ReleaseId,
45 repo_key: String,
46 tag_name: String,
47 target_commitish: String,
48 author: String,
49 ) -> Self {
50 let now = SystemTime::now()
51 .duration_since(UNIX_EPOCH)
52 .unwrap()
53 .as_secs();
54
55 Self {
56 id,
57 repo_key,
58 tag_name,
59 target_commitish,
60 name: None,
61 body: None,
62 draft: false,
63 prerelease: false,
64 author,
65 assets: Vec::new(),
66 created_at: now,
67 published_at: Some(now),
68 }
69 }
70
71 pub fn is_publishable(&self) -> bool {
73 !self.draft && !self.prerelease
74 }
75
76 pub fn publish(&mut self) {
78 self.draft = false;
79 self.published_at = Some(
80 SystemTime::now()
81 .duration_since(UNIX_EPOCH)
82 .unwrap()
83 .as_secs(),
84 );
85 }
86
87 pub fn add_asset(&mut self, asset: ReleaseAsset) {
89 self.assets.push(asset);
90 }
91
92 pub fn remove_asset(&mut self, asset_id: AssetId) -> Option<ReleaseAsset> {
94 if let Some(pos) = self.assets.iter().position(|a| a.id == asset_id) {
95 Some(self.assets.remove(pos))
96 } else {
97 None
98 }
99 }
100
101 pub fn to_response(&self) -> ReleaseResponse {
103 ReleaseResponse {
104 id: self.id,
105 tag_name: self.tag_name.clone(),
106 target_commitish: self.target_commitish.clone(),
107 name: self.name.clone(),
108 body: self.body.clone(),
109 draft: self.draft,
110 prerelease: self.prerelease,
111 author: AuthorInfo {
112 login: self.author.clone(),
113 },
114 assets: self.assets.iter().map(|a| a.to_response()).collect(),
115 created_at: format_timestamp(self.created_at),
116 published_at: self.published_at.map(format_timestamp),
117 }
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ReleaseAsset {
124 pub id: AssetId,
126 pub release_id: ReleaseId,
128 pub name: String,
130 pub label: Option<String>,
132 pub content_type: String,
134 pub size: u64,
136 pub download_count: u64,
138 pub content_hash: String,
140 pub created_at: u64,
142 pub uploader: String,
144}
145
146impl ReleaseAsset {
147 pub fn new(
149 id: AssetId,
150 release_id: ReleaseId,
151 name: String,
152 content_type: String,
153 size: u64,
154 content_hash: String,
155 uploader: String,
156 ) -> Self {
157 Self {
158 id,
159 release_id,
160 name,
161 label: None,
162 content_type,
163 size,
164 download_count: 0,
165 content_hash,
166 created_at: SystemTime::now()
167 .duration_since(UNIX_EPOCH)
168 .unwrap()
169 .as_secs(),
170 uploader,
171 }
172 }
173
174 pub fn increment_downloads(&mut self) {
176 self.download_count += 1;
177 }
178
179 pub fn to_response(&self) -> AssetResponse {
181 AssetResponse {
182 id: self.id,
183 name: self.name.clone(),
184 label: self.label.clone(),
185 content_type: self.content_type.clone(),
186 size: self.size,
187 download_count: self.download_count,
188 created_at: format_timestamp(self.created_at),
189 uploader: AuthorInfo {
190 login: self.uploader.clone(),
191 },
192 }
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct AuthorInfo {
199 pub login: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct ReleaseResponse {
206 pub id: ReleaseId,
208 pub tag_name: String,
210 pub target_commitish: String,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub name: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub body: Option<String>,
218 pub draft: bool,
220 pub prerelease: bool,
222 pub author: AuthorInfo,
224 pub assets: Vec<AssetResponse>,
226 pub created_at: String,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub published_at: Option<String>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct AssetResponse {
236 pub id: AssetId,
238 pub name: String,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub label: Option<String>,
243 pub content_type: String,
245 pub size: u64,
247 pub download_count: u64,
249 pub created_at: String,
251 pub uploader: AuthorInfo,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct CreateReleaseRequest {
258 pub tag_name: String,
260 #[serde(default)]
262 pub target_commitish: Option<String>,
263 #[serde(default)]
265 pub name: Option<String>,
266 #[serde(default)]
268 pub body: Option<String>,
269 #[serde(default)]
271 pub draft: bool,
272 #[serde(default)]
274 pub prerelease: bool,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, Default)]
279pub struct UpdateReleaseRequest {
280 #[serde(default)]
282 pub tag_name: Option<String>,
283 #[serde(default)]
285 pub target_commitish: Option<String>,
286 #[serde(default)]
288 pub name: Option<String>,
289 #[serde(default)]
291 pub body: Option<String>,
292 #[serde(default)]
294 pub draft: Option<bool>,
295 #[serde(default)]
297 pub prerelease: Option<bool>,
298}
299
300fn format_timestamp(timestamp: u64) -> String {
302 let secs_per_day = 86400;
303 let secs_per_hour = 3600;
304 let secs_per_min = 60;
305
306 let mut days = timestamp / secs_per_day;
307 let remaining = timestamp % secs_per_day;
308 let hours = remaining / secs_per_hour;
309 let remaining = remaining % secs_per_hour;
310 let minutes = remaining / secs_per_min;
311 let seconds = remaining % secs_per_min;
312
313 let mut year = 1970;
314 loop {
315 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
316 if days < days_in_year {
317 break;
318 }
319 days -= days_in_year;
320 year += 1;
321 }
322
323 let days_in_month = if is_leap_year(year) {
324 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
325 } else {
326 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
327 };
328
329 let mut month = 0;
330 for (i, &dim) in days_in_month.iter().enumerate() {
331 if days < dim as u64 {
332 month = i + 1;
333 break;
334 }
335 days -= dim as u64;
336 }
337 let day = days + 1;
338
339 format!(
340 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
341 year, month, day, hours, minutes, seconds
342 )
343}
344
345fn is_leap_year(year: u64) -> bool {
346 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_release_creation() {
355 let release = Release::new(
356 1,
357 "alice/repo".to_string(),
358 "v1.0.0".to_string(),
359 "main".to_string(),
360 "alice".to_string(),
361 );
362
363 assert_eq!(release.id, 1);
364 assert_eq!(release.tag_name, "v1.0.0");
365 assert!(!release.draft);
366 assert!(!release.prerelease);
367 assert!(release.published_at.is_some());
368 }
369
370 #[test]
371 fn test_draft_release() {
372 let mut release = Release::new(
373 1,
374 "alice/repo".to_string(),
375 "v1.0.0".to_string(),
376 "main".to_string(),
377 "alice".to_string(),
378 );
379
380 release.draft = true;
381 release.published_at = None;
382
383 assert!(!release.is_publishable());
384
385 release.publish();
386 assert!(release.is_publishable());
387 assert!(release.published_at.is_some());
388 }
389
390 #[test]
391 fn test_asset_management() {
392 let mut release = Release::new(
393 1,
394 "alice/repo".to_string(),
395 "v1.0.0".to_string(),
396 "main".to_string(),
397 "alice".to_string(),
398 );
399
400 let asset = ReleaseAsset::new(
401 1,
402 1,
403 "app-v1.0.0.tar.gz".to_string(),
404 "application/gzip".to_string(),
405 1024,
406 "abc123".to_string(),
407 "alice".to_string(),
408 );
409
410 release.add_asset(asset);
411 assert_eq!(release.assets.len(), 1);
412
413 let removed = release.remove_asset(1);
414 assert!(removed.is_some());
415 assert_eq!(release.assets.len(), 0);
416 }
417
418 #[test]
419 fn test_asset_downloads() {
420 let mut asset = ReleaseAsset::new(
421 1,
422 1,
423 "app.tar.gz".to_string(),
424 "application/gzip".to_string(),
425 1024,
426 "abc123".to_string(),
427 "alice".to_string(),
428 );
429
430 assert_eq!(asset.download_count, 0);
431 asset.increment_downloads();
432 asset.increment_downloads();
433 assert_eq!(asset.download_count, 2);
434 }
435}