1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
use serde::Deserialize;
use crate::{
error::DownloadError,
platform::fetch_releases_json,
traits::{Asset, Platform, Release},
};
pub struct GitLab;
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabRelease {
pub name: String,
pub tag_name: String,
pub upcoming_release: bool,
pub released_at: String,
pub description: Option<String>,
pub assets: GitLabAssets,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabAssets {
pub links: Vec<GitLabAsset>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabAsset {
pub name: String,
pub direct_asset_url: String,
}
impl Platform for GitLab {
type Release = GitLabRelease;
const API_BASE: &'static str = "https://gitlab.com";
const TOKEN_ENV: [&str; 2] = ["GITLAB_TOKEN", "GL_TOKEN"];
/// Fetches releases for a GitLab project, optionally narrowing to a specific tag.
///
/// The `project` is the repository identifier (for example `"group/name"` or a numeric project ID).
/// If `tag` is provided and the `project` consists only of digits, the fetch targets that single release; otherwise the fetch returns the project's release list.
///
/// # Parameters
///
/// - `project`: repository identifier or numeric project ID.
/// - `tag`: optional release tag to narrow the request.
///
/// # Returns
///
/// `Ok(Vec<GitLabRelease>)` with the fetched releases on success, or a `DownloadError` on failure.
///
/// # Examples
///
/// ```no_run
/// use soar_dl::gitlab::GitLab;
/// use soar_dl::traits::Platform;
///
/// // Fetch all releases for a namespaced project
/// let _ = GitLab::fetch_releases("group/project", None);
///
/// // Fetch a specific release when using a numeric project ID
/// let _ = GitLab::fetch_releases("123456", Some("v1.0.0"));
/// ```
fn fetch_releases(
project: &str,
tag: Option<&str>,
) -> Result<Vec<Self::Release>, DownloadError> {
let encoded_project = project.replace('/', "%2F");
let path = match tag {
Some(t) if project.chars().all(char::is_numeric) => {
let encoded_tag =
url::form_urlencoded::byte_serialize(t.as_bytes()).collect::<String>();
format!(
"/api/v4/projects/{}/releases/{}",
encoded_project, encoded_tag
)
}
_ => format!("/api/v4/projects/{}/releases", encoded_project),
};
fetch_releases_json::<Self::Release>(&path, Self::API_BASE, Self::TOKEN_ENV)
}
}
impl Release for GitLabRelease {
type Asset = GitLabAsset;
/// The release's name
///
/// # Examples
///
/// ```
/// use soar_dl::gitlab::{GitLabAssets, GitLabRelease};
/// use soar_dl::traits::Release;
///
/// let r = GitLabRelease {
/// name: "v1.0".into(),
/// tag_name: "v1.0".into(),
/// upcoming_release: false,
/// released_at: "".into(),
/// description: None,
/// assets: GitLabAssets { links: vec![] },
/// };
/// assert_eq!(r.name(), "v1.0");
/// ```
fn name(&self) -> &str {
&self.name
}
/// Get the release's tag name.
///
/// # Examples
///
/// ```
/// use soar_dl::gitlab::{GitLabAssets, GitLabRelease};
/// use soar_dl::traits::Release;
///
/// let r = GitLabRelease {
/// name: "Release".into(),
/// tag_name: "v1.0.0".into(),
/// upcoming_release: false,
/// released_at: "2025-01-01T00:00:00Z".into(),
/// description: None,
/// assets: GitLabAssets { links: vec![] },
/// };
/// assert_eq!(r.tag(), "v1.0.0");
/// ```
fn tag(&self) -> &str {
&self.tag_name
}
/// Indicates whether the release is marked as upcoming.
///
/// # Returns
///
/// `true` if the release is marked as upcoming, `false` otherwise.
///
/// # Examples
///
/// ```
/// use soar_dl::gitlab::{GitLabAssets, GitLabRelease};
/// use soar_dl::traits::Release;
///
/// let rel = GitLabRelease {
/// name: "v1".to_string(),
/// tag_name: "v1".to_string(),
/// upcoming_release: true,
/// released_at: "".to_string(),
/// description: None,
/// assets: GitLabAssets { links: vec![] },
/// };
/// assert!(rel.is_prerelease());
/// ```
fn is_prerelease(&self) -> bool {
self.upcoming_release
}
/// Get the release's published date/time string.
///
/// # Examples
///
/// ```
/// use soar_dl::gitlab::{GitLabAssets, GitLabRelease};
/// use soar_dl::traits::Release;
///
/// let r = GitLabRelease {
/// name: String::from("v1"),
/// tag_name: String::from("v1"),
/// upcoming_release: false,
/// released_at: String::from("2020-01-01T00:00:00Z"),
/// description: None,
/// assets: GitLabAssets { links: vec![] },
/// };
/// assert_eq!(r.published_at(), "2020-01-01T00:00:00Z");
/// ```
fn published_at(&self) -> &str {
&self.released_at
}
/// A slice of assets associated with the release.
///
/// # Examples
///
/// ```
/// use soar_dl::gitlab::{GitLabAsset, GitLabAssets, GitLabRelease};
/// use soar_dl::traits::{Asset, Release};
///
/// let asset = GitLabAsset { name: "file.tar.gz".into(), direct_asset_url: "https://example.com/file.tar.gz".into() };
/// let assets = GitLabAssets { links: vec![asset.clone()] };
/// let release = GitLabRelease {
/// name: "v1.0".into(),
/// tag_name: "v1.0".into(),
/// upcoming_release: false,
/// released_at: "2025-10-31T00:00:00Z".into(),
/// description: None,
/// assets,
/// };
/// let slice = release.assets();
/// assert_eq!(slice.len(), 1);
/// assert_eq!(slice[0].name(), "file.tar.gz");
/// ```
///
/// # Returns
///
/// A slice of the release's assets.
fn assets(&self) -> &[Self::Asset] {
&self.assets.links
}
fn body(&self) -> Option<&str> {
self.description.as_deref()
}
}
impl Asset for GitLabAsset {
/// Gets the asset's name.
///
/// # Examples
///
/// ```
/// use soar_dl::gitlab::GitLabAsset;
/// use soar_dl::traits::Asset;
///
/// let asset = GitLabAsset { name: String::from("v1.0.0"), direct_asset_url: String::from("https://example") };
/// assert_eq!(asset.name(), "v1.0.0");
/// ```
fn name(&self) -> &str {
&self.name
}
/// Returns the asset size when available; for GitLab assets this is not provided.
///
/// This implementation always reports that size information is unavailable.
///
/// # Examples
///
/// ```
/// use soar_dl::gitlab::GitLabAsset;
/// use soar_dl::traits::Asset;
///
/// let asset = GitLabAsset {
/// name: "example".into(),
/// direct_asset_url: "https://gitlab.com/example".into(),
/// };
/// assert_eq!(asset.size(), None);
/// ```
fn size(&self) -> Option<u64> {
None
}
/// Returns the direct URL of the asset.
///
/// # Examples
///
/// ```
/// use soar_dl::gitlab::GitLabAsset;
/// use soar_dl::traits::Asset;
///
/// let asset = GitLabAsset {
/// name: String::from("example"),
/// direct_asset_url: String::from("https://example.com/download"),
/// };
/// assert_eq!(asset.url(), "https://example.com/download");
/// ```
fn url(&self) -> &str {
&self.direct_asset_url
}
}