soar_dl/github.rs
1use serde::Deserialize;
2
3use crate::{
4 error::DownloadError,
5 platform::fetch_releases_json,
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_BASE: &'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_releases_json::<Self::Release>(&path, Self::API_BASE, Self::TOKEN_ENV)
73 }
74}
75
76impl Release for GithubRelease {
77 type Asset = GithubAsset;
78
79 /// The release's name, or an empty string if the release has no name.
80 ///
81 /// # Examples
82 ///
83 /// ```
84 /// use soar_dl::github::GithubRelease;
85 /// use soar_dl::traits::Release;
86 ///
87 /// let r = GithubRelease {
88 /// name: Some("v1.0".into()),
89 /// tag_name: "v1.0".into(),
90 /// prerelease: false,
91 /// published_at: "".into(),
92 /// body: None,
93 /// assets: vec![],
94 /// };
95 /// assert_eq!(r.name(), "v1.0");
96 ///
97 /// let unnamed = GithubRelease {
98 /// name: None,
99 /// tag_name: "v1.1".into(),
100 /// prerelease: false,
101 /// published_at: "".into(),
102 /// body: None,
103 /// assets: vec![],
104 /// };
105 /// assert_eq!(unnamed.name(), "");
106 /// ```
107 fn name(&self) -> &str {
108 self.name.as_deref().unwrap_or("")
109 }
110
111 /// Get the release tag as a string slice.
112 ///
113 /// # Examples
114 ///
115 /// ```
116 /// use soar_dl::github::GithubRelease;
117 /// use soar_dl::traits::Release;
118 ///
119 /// let release = GithubRelease {
120 /// name: None,
121 /// tag_name: "v1.0.0".into(),
122 /// prerelease: false,
123 /// published_at: "".into(),
124 /// body: None,
125 /// assets: vec![],
126 /// };
127 /// assert_eq!(release.tag(), "v1.0.0");
128 /// ```
129 ///
130 /// # Returns
131 ///
132 /// `&str` containing the release tag.
133 fn tag(&self) -> &str {
134 &self.tag_name
135 }
136
137 /// Indicates whether the release is marked as a prerelease.
138 ///
139 /// # Returns
140 ///
141 /// `true` if the release is marked as a prerelease, `false` otherwise.
142 ///
143 /// # Examples
144 ///
145 /// ```
146 /// use soar_dl::github::GithubRelease;
147 /// use soar_dl::traits::Release;
148 ///
149 /// let r = GithubRelease {
150 /// name: None,
151 /// tag_name: "v1.0.0".to_string(),
152 /// prerelease: true,
153 /// published_at: "".to_string(),
154 /// body: None,
155 /// assets: vec![],
156 /// };
157 /// assert!(r.is_prerelease());
158 /// ```
159 fn is_prerelease(&self) -> bool {
160 self.prerelease
161 }
162
163 /// Returns the release's publication timestamp as an RFC 3339 formatted string.
164 ///
165 /// # Examples
166 ///
167 /// ```
168 /// use soar_dl::github::GithubRelease;
169 /// use soar_dl::traits::Release;
170 ///
171 /// let r = GithubRelease {
172 /// name: None,
173 /// tag_name: "v1.0.0".into(),
174 /// prerelease: false,
175 /// published_at: "2021-01-01T00:00:00Z".into(),
176 /// body: None,
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 /// body: None,
207 /// assets: vec![asset],
208 /// };
209 ///
210 /// assert_eq!(release.assets().len(), 1);
211 /// ```
212 fn assets(&self) -> &[Self::Asset] {
213 &self.assets
214 }
215
216 fn body(&self) -> Option<&str> {
217 self.body.as_deref()
218 }
219}
220
221impl Asset for GithubAsset {
222 /// Retrieves the asset's name.
223 ///
224 /// # Examples
225 ///
226 /// ```
227 /// use soar_dl::github::GithubAsset;
228 /// use soar_dl::traits::Asset;
229 ///
230 /// let asset = GithubAsset {
231 /// name: "file.zip".to_string(),
232 /// size: 123,
233 /// browser_download_url: "https://example.com/file.zip".to_string(),
234 /// };
235 /// assert_eq!(asset.name(), "file.zip");
236 /// ```
237 ///
238 /// # Returns
239 ///
240 /// A `&str` containing the asset's name.
241 fn name(&self) -> &str {
242 &self.name
243 }
244
245 /// Asset size in bytes.
246 ///
247 /// # Returns
248 ///
249 /// `Some(size)` containing the asset size in bytes.
250 ///
251 /// # Examples
252 ///
253 /// ```
254 /// use soar_dl::github::GithubAsset;
255 /// use soar_dl::traits::Asset;
256 ///
257 /// let asset = GithubAsset { name: "file".into(), size: 12345, browser_download_url: "https://example.com".into() };
258 /// assert_eq!(asset.size(), Some(12345));
259 /// ```
260 fn size(&self) -> Option<u64> {
261 Some(self.size)
262 }
263
264 /// Returns the asset's browser download URL.
265 ///
266 /// # Examples
267 ///
268 /// ```
269 /// use soar_dl::github::GithubAsset;
270 /// use soar_dl::traits::Asset;
271 ///
272 /// let asset = GithubAsset {
273 /// name: "example".into(),
274 /// size: 123,
275 /// browser_download_url: "https://example.com/download".into(),
276 /// };
277 /// assert_eq!(asset.url(), "https://example.com/download");
278 /// ```
279 fn url(&self) -> &str {
280 &self.browser_download_url
281 }
282}