Skip to main content

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}