soar_dl/github.rs
1use serde::Deserialize;
2
3use crate::{
4 error::DownloadError,
5 platform::fetch_with_fallback,
6 traits::{Asset, Platform, Release},
7};
8
9pub struct Github;
10
11#[derive(Debug, Clone, Deserialize)]
12pub struct GithubRelease {
13 pub name: Option<String>,
14 pub tag_name: String,
15 pub prerelease: bool,
16 pub published_at: String,
17 pub body: Option<String>,
18 pub assets: Vec<GithubAsset>,
19}
20
21#[derive(Debug, Clone, Deserialize)]
22pub struct GithubAsset {
23 pub name: String,
24 pub size: u64,
25 pub browser_download_url: String,
26}
27
28impl Platform for Github {
29 type Release = GithubRelease;
30
31 const API_PKGFORGE: &'static str = "https://api.gh.pkgforge.dev";
32 const API_UPSTREAM: &'static str = "https://api.github.com";
33 const TOKEN_ENV: [&str; 2] = ["GITHUB_TOKEN", "GH_TOKEN"];
34
35 /// Fetches releases for the given GitHub repository, optionally filtered by a specific tag.
36 ///
37 /// If `tag` is provided, fetches the release that matches that tag; otherwise fetches the repository's releases (up to 100 per page).
38 ///
39 /// # Arguments
40 ///
41 /// * `project` — repository identifier in the form "owner/repo".
42 /// * `tag` — optional release tag to filter the results.
43 ///
44 /// # Returns
45 ///
46 /// `Ok` with a vector of releases on success, or `Err(DownloadError)` on failure.
47 ///
48 /// # Examples
49 ///
50 /// ```no_run
51 /// use soar_dl::github::Github;
52 /// use soar_dl::traits::{Platform, Release};
53 ///
54 /// let releases = Github::fetch_releases("rust-lang/rust", None).unwrap();
55 /// assert!(releases.iter().all(|r| r.tag().len() > 0));
56 /// ```
57 fn fetch_releases(
58 project: &str,
59 tag: Option<&str>,
60 ) -> Result<Vec<Self::Release>, DownloadError> {
61 let path = match tag {
62 Some(tag) => {
63 let encoded_tag =
64 url::form_urlencoded::byte_serialize(tag.as_bytes()).collect::<String>();
65 format!(
66 "/repos/{project}/releases/tags/{}?per_page=100",
67 encoded_tag
68 )
69 }
70 None => format!("/repos/{project}/releases?per_page=100"),
71 };
72
73 fetch_with_fallback::<Self::Release>(
74 &path,
75 Self::API_UPSTREAM,
76 Self::API_PKGFORGE,
77 Self::TOKEN_ENV,
78 )
79 }
80}
81
82impl Release for GithubRelease {
83 type Asset = GithubAsset;
84
85 /// The release's name, or an empty string if the release has no name.
86 ///
87 /// # Examples
88 ///
89 /// ```
90 /// use soar_dl::github::GithubRelease;
91 /// use soar_dl::traits::Release;
92 ///
93 /// let r = GithubRelease {
94 /// name: Some("v1.0".into()),
95 /// tag_name: "v1.0".into(),
96 /// prerelease: false,
97 /// published_at: "".into(),
98 /// body: None,
99 /// assets: vec![],
100 /// };
101 /// assert_eq!(r.name(), "v1.0");
102 ///
103 /// let unnamed = GithubRelease {
104 /// name: None,
105 /// tag_name: "v1.1".into(),
106 /// prerelease: false,
107 /// published_at: "".into(),
108 /// body: None,
109 /// assets: vec![],
110 /// };
111 /// assert_eq!(unnamed.name(), "");
112 /// ```
113 fn name(&self) -> &str {
114 self.name.as_deref().unwrap_or("")
115 }
116
117 /// Get the release tag as a string slice.
118 ///
119 /// # Examples
120 ///
121 /// ```
122 /// use soar_dl::github::GithubRelease;
123 /// use soar_dl::traits::Release;
124 ///
125 /// let release = GithubRelease {
126 /// name: None,
127 /// tag_name: "v1.0.0".into(),
128 /// prerelease: false,
129 /// published_at: "".into(),
130 /// body: None,
131 /// assets: vec![],
132 /// };
133 /// assert_eq!(release.tag(), "v1.0.0");
134 /// ```
135 ///
136 /// # Returns
137 ///
138 /// `&str` containing the release tag.
139 fn tag(&self) -> &str {
140 &self.tag_name
141 }
142
143 /// Indicates whether the release is marked as a prerelease.
144 ///
145 /// # Returns
146 ///
147 /// `true` if the release is marked as a prerelease, `false` otherwise.
148 ///
149 /// # Examples
150 ///
151 /// ```
152 /// use soar_dl::github::GithubRelease;
153 /// use soar_dl::traits::Release;
154 ///
155 /// let r = GithubRelease {
156 /// name: None,
157 /// tag_name: "v1.0.0".to_string(),
158 /// prerelease: true,
159 /// published_at: "".to_string(),
160 /// body: None,
161 /// assets: vec![],
162 /// };
163 /// assert!(r.is_prerelease());
164 /// ```
165 fn is_prerelease(&self) -> bool {
166 self.prerelease
167 }
168
169 /// Returns the release's publication timestamp as an RFC 3339 formatted string.
170 ///
171 /// # Examples
172 ///
173 /// ```
174 /// use soar_dl::github::GithubRelease;
175 /// use soar_dl::traits::Release;
176 ///
177 /// let r = GithubRelease {
178 /// name: None,
179 /// tag_name: "v1.0.0".into(),
180 /// prerelease: false,
181 /// published_at: "2021-01-01T00:00:00Z".into(),
182 /// body: None,
183 /// assets: vec![],
184 /// };
185 /// assert_eq!(r.published_at(), "2021-01-01T00:00:00Z");
186 /// ```
187 fn published_at(&self) -> &str {
188 &self.published_at
189 }
190
191 /// Get a slice of assets associated with the release.
192 ///
193 /// The slice contains the release's assets in declaration order.
194 ///
195 /// # Examples
196 ///
197 /// ```
198 /// use soar_dl::github::{GithubRelease, GithubAsset};
199 /// use soar_dl::traits::Release;
200 ///
201 /// let asset = GithubAsset {
202 /// name: "example.zip".into(),
203 /// size: 1024,
204 /// browser_download_url: "https://example.com/example.zip".into(),
205 /// };
206 ///
207 /// let release = GithubRelease {
208 /// name: Some("v1.0".into()),
209 /// tag_name: "v1.0".into(),
210 /// prerelease: false,
211 /// published_at: "2025-01-01T00:00:00Z".into(),
212 /// body: None,
213 /// assets: vec![asset],
214 /// };
215 ///
216 /// assert_eq!(release.assets().len(), 1);
217 /// ```
218 fn assets(&self) -> &[Self::Asset] {
219 &self.assets
220 }
221
222 fn body(&self) -> Option<&str> {
223 self.body.as_deref()
224 }
225}
226
227impl Asset for GithubAsset {
228 /// Retrieves the asset's name.
229 ///
230 /// # Examples
231 ///
232 /// ```
233 /// use soar_dl::github::GithubAsset;
234 /// use soar_dl::traits::Asset;
235 ///
236 /// let asset = GithubAsset {
237 /// name: "file.zip".to_string(),
238 /// size: 123,
239 /// browser_download_url: "https://example.com/file.zip".to_string(),
240 /// };
241 /// assert_eq!(asset.name(), "file.zip");
242 /// ```
243 ///
244 /// # Returns
245 ///
246 /// A `&str` containing the asset's name.
247 fn name(&self) -> &str {
248 &self.name
249 }
250
251 /// Asset size in bytes.
252 ///
253 /// # Returns
254 ///
255 /// `Some(size)` containing the asset size in bytes.
256 ///
257 /// # Examples
258 ///
259 /// ```
260 /// use soar_dl::github::GithubAsset;
261 /// use soar_dl::traits::Asset;
262 ///
263 /// let asset = GithubAsset { name: "file".into(), size: 12345, browser_download_url: "https://example.com".into() };
264 /// assert_eq!(asset.size(), Some(12345));
265 /// ```
266 fn size(&self) -> Option<u64> {
267 Some(self.size)
268 }
269
270 /// Returns the asset's browser download URL.
271 ///
272 /// # Examples
273 ///
274 /// ```
275 /// use soar_dl::github::GithubAsset;
276 /// use soar_dl::traits::Asset;
277 ///
278 /// let asset = GithubAsset {
279 /// name: "example".into(),
280 /// size: 123,
281 /// browser_download_url: "https://example.com/download".into(),
282 /// };
283 /// assert_eq!(asset.url(), "https://example.com/download");
284 /// ```
285 fn url(&self) -> &str {
286 &self.browser_download_url
287 }
288}