Skip to main content

guts_compat/
release.rs

1//! Release and tag management types.
2
3use serde::{Deserialize, Serialize};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6/// Unique identifier for a release.
7pub type ReleaseId = u64;
8
9/// Unique identifier for a release asset.
10pub type AssetId = u64;
11
12/// A release (tagged version) in a repository.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Release {
15    /// Unique release ID.
16    pub id: ReleaseId,
17    /// Repository key (owner/name).
18    pub repo_key: String,
19    /// Tag name (e.g., "v1.0.0").
20    pub tag_name: String,
21    /// Target branch or commit SHA.
22    pub target_commitish: String,
23    /// Release title (optional).
24    pub name: Option<String>,
25    /// Markdown body (changelog, notes).
26    pub body: Option<String>,
27    /// Whether this is a draft release.
28    pub draft: bool,
29    /// Whether this is a prerelease.
30    pub prerelease: bool,
31    /// Username of the author.
32    pub author: String,
33    /// Attached assets.
34    pub assets: Vec<ReleaseAsset>,
35    /// When the release was created.
36    pub created_at: u64,
37    /// When the release was published (None if draft).
38    pub published_at: Option<u64>,
39}
40
41impl Release {
42    /// Create a new release.
43    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    /// Check if this is the latest non-prerelease, non-draft release.
72    pub fn is_publishable(&self) -> bool {
73        !self.draft && !self.prerelease
74    }
75
76    /// Publish a draft release.
77    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    /// Add an asset to this release.
88    pub fn add_asset(&mut self, asset: ReleaseAsset) {
89        self.assets.push(asset);
90    }
91
92    /// Remove an asset by ID.
93    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    /// Convert to API response.
102    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/// An asset attached to a release.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ReleaseAsset {
124    /// Unique asset ID.
125    pub id: AssetId,
126    /// Release this asset belongs to.
127    pub release_id: ReleaseId,
128    /// Filename.
129    pub name: String,
130    /// Optional label for display.
131    pub label: Option<String>,
132    /// MIME content type.
133    pub content_type: String,
134    /// Size in bytes.
135    pub size: u64,
136    /// Download count.
137    pub download_count: u64,
138    /// SHA-256 hash of content.
139    pub content_hash: String,
140    /// When the asset was uploaded.
141    pub created_at: u64,
142    /// Username of uploader.
143    pub uploader: String,
144}
145
146impl ReleaseAsset {
147    /// Create a new asset.
148    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    /// Increment the download count.
175    pub fn increment_downloads(&mut self) {
176        self.download_count += 1;
177    }
178
179    /// Convert to API response.
180    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/// Author information for responses.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct AuthorInfo {
199    /// Username.
200    pub login: String,
201}
202
203/// Release response for API.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct ReleaseResponse {
206    /// Release ID.
207    pub id: ReleaseId,
208    /// Tag name.
209    pub tag_name: String,
210    /// Target branch/commit.
211    pub target_commitish: String,
212    /// Release title.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub name: Option<String>,
215    /// Markdown body.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub body: Option<String>,
218    /// Whether this is a draft.
219    pub draft: bool,
220    /// Whether this is a prerelease.
221    pub prerelease: bool,
222    /// Author information.
223    pub author: AuthorInfo,
224    /// Attached assets.
225    pub assets: Vec<AssetResponse>,
226    /// Creation timestamp.
227    pub created_at: String,
228    /// Publication timestamp.
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub published_at: Option<String>,
231}
232
233/// Asset response for API.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct AssetResponse {
236    /// Asset ID.
237    pub id: AssetId,
238    /// Filename.
239    pub name: String,
240    /// Optional label.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub label: Option<String>,
243    /// MIME content type.
244    pub content_type: String,
245    /// Size in bytes.
246    pub size: u64,
247    /// Download count.
248    pub download_count: u64,
249    /// Upload timestamp.
250    pub created_at: String,
251    /// Uploader information.
252    pub uploader: AuthorInfo,
253}
254
255/// Request to create a release.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct CreateReleaseRequest {
258    /// Tag name (required).
259    pub tag_name: String,
260    /// Target branch or commit (default: default branch).
261    #[serde(default)]
262    pub target_commitish: Option<String>,
263    /// Release title.
264    #[serde(default)]
265    pub name: Option<String>,
266    /// Markdown body.
267    #[serde(default)]
268    pub body: Option<String>,
269    /// Create as draft.
270    #[serde(default)]
271    pub draft: bool,
272    /// Mark as prerelease.
273    #[serde(default)]
274    pub prerelease: bool,
275}
276
277/// Request to update a release.
278#[derive(Debug, Clone, Serialize, Deserialize, Default)]
279pub struct UpdateReleaseRequest {
280    /// New tag name.
281    #[serde(default)]
282    pub tag_name: Option<String>,
283    /// New target.
284    #[serde(default)]
285    pub target_commitish: Option<String>,
286    /// New title.
287    #[serde(default)]
288    pub name: Option<String>,
289    /// New body.
290    #[serde(default)]
291    pub body: Option<String>,
292    /// Update draft status.
293    #[serde(default)]
294    pub draft: Option<bool>,
295    /// Update prerelease status.
296    #[serde(default)]
297    pub prerelease: Option<bool>,
298}
299
300/// Format a Unix timestamp as ISO 8601.
301fn 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}