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