Skip to main content

ib_update/github/
mod.rs

1/*!
2GitHub Releases update source.
3
4This module queries the [GitHub REST API](https://docs.github.com/rest/releases/releases#list-releases)
5for the latest release of a given repository and compares it against the current version
6to determine whether an update is available.
7*/
8use serde::Deserialize;
9use thiserror::Error;
10
11#[cfg(all(feature = "github-extra", feature = "multi-server"))]
12use update_config_builder::{IsUnset, SetBaseUrls, State};
13
14#[cfg(feature = "async")]
15mod r#async;
16#[cfg(feature = "blocking")]
17mod blocking;
18
19#[cfg(feature = "async")]
20pub use r#async::AsyncUpdateChecker;
21#[cfg(feature = "blocking")]
22pub use blocking::UpdateChecker;
23
24/// Errors that can occur during an update check.
25#[derive(Debug, Error)]
26pub enum Error {
27    /// Reused when possible to save binary size.
28    #[error(transparent)]
29    Nyquest(#[from] nyquest::Error),
30
31    /// GitHub:
32    /// ```json
33    /// {"message":"API rate limit exceeded for 1.2.3.4. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)","documentation_url":"https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"}
34    /// ```
35    #[error("rate limit")]
36    RateLimit,
37}
38
39impl Error {
40    pub const NO_RELEASE: Error = Error::Nyquest(nyquest::Error::NonSuccessfulStatusCode(
41        nyquest::StatusCode::new(404),
42    ));
43}
44
45/// A GitHub release as returned by the `/releases/latest` endpoint.
46///
47/// Example: https://api.github.com/repos/Chaoses-Ib/IbEverythingExt/releases/latest
48#[derive(Debug, Clone, Deserialize)]
49#[cfg_attr(test, derive(PartialEq))]
50pub struct Release {
51    /// The release tag name (e.g. `"v1.2.3"`).
52    #[serde(rename = "tag_name")]
53    pub tag: String,
54
55    /// The human-readable release name (may differ from the tag).
56    pub name: Option<String>,
57
58    /// Whether this is marked as a pre-release on GitHub.
59    #[serde(default)]
60    pub prerelease: bool,
61
62    /// ISO 8601 timestamp of when the release was created.
63    pub created_at: String,
64
65    /// ISO 8601 timestamp of the publish.
66    pub published_at: Option<String>,
67
68    /// Markdown release notes / changelog.
69    pub body: Option<String>,
70
71    /// URL to the release page on GitHub.
72    pub html_url: String,
73
74    /// Uploaded binaries / archives.
75    #[serde(default)]
76    pub assets: Vec<ReleaseAsset>,
77}
78
79impl Release {
80    /// Check the HTTP status, returning an error for non-successful responses.
81    fn check_status(status: nyquest::StatusCode, body: &[u8]) -> Result<(), Error> {
82        if !status.is_successful() {
83            // body.contains("rate")
84            if status == 403 && body.array_windows::<4>().any(|x| x == b"rate") {
85                return Err(Error::RateLimit);
86            }
87            return Err(Error::Nyquest(nyquest::Error::NonSuccessfulStatusCode(
88                status,
89            )));
90        }
91        Ok(())
92    }
93
94    /// Parse a HTTP response body into a [`Release`].
95    ///
96    /// - `body`: Using `Vec<u8>` is 7/10.5 KiB smaller than `String`,
97    ///   plus `nyquest` `json()` uses `Vec<u8>` too.
98    pub(crate) fn parse(status: nyquest::StatusCode, body: Vec<u8>) -> Result<Release, Error> {
99        Self::check_status(status, &body)?;
100        let release: Release = serde_json::from_slice(&body).map_err(nyquest::Error::from)?;
101        Ok(release)
102    }
103
104    /// Parse a HTTP response body into an array of [`Release`]s.
105    pub(crate) fn parse_array(
106        status: nyquest::StatusCode,
107        body: Vec<u8>,
108    ) -> Result<Vec<Release>, Error> {
109        Self::check_status(status, &body)?;
110        let releases: Vec<Release> = serde_json::from_slice(&body).map_err(nyquest::Error::from)?;
111        Ok(releases)
112    }
113}
114
115/// A single release asset (binary, archive, etc.) attached to a GitHub release.
116#[derive(Debug, Clone, Deserialize)]
117#[cfg_attr(test, derive(PartialEq))]
118pub struct ReleaseAsset {
119    /// File name of the asset.
120    pub name: String,
121
122    /// Direct download URL.
123    #[serde(rename = "browser_download_url")]
124    pub download_url: String,
125
126    /// Size in bytes (may be null).
127    pub size: u64,
128
129    /// SHA-256 digest (format: `"sha256:<hex>"`).
130    pub digest: Option<String>,
131
132    /// Number of times this asset has been downloaded.
133    ///
134    /// Size +1.5/1.5 KiB
135    #[cfg(feature = "github-extra")]
136    pub download_count: u64,
137}
138
139/// Result of an update check — contains the latest release and comparison info.
140#[derive(Debug, Clone)]
141pub struct UpdateInfo {
142    /// The current version that was compared against (if any was provided).
143    current_version: Option<String>,
144    /// The latest release from GitHub.
145    latest: Release,
146    /// `true` if an update is recommended.
147    has_update: bool,
148}
149
150impl UpdateInfo {
151    /// Build an [`UpdateInfo`] from a [`Release`] and optional current version.
152    pub(crate) fn new(current_version: Option<&str>, latest: Release) -> UpdateInfo {
153        let has_update = match current_version {
154            Some(cv) => crate::git::is_newer(cv, &latest.tag),
155            None => true,
156        };
157
158        UpdateInfo {
159            current_version: current_version.map(str::to_string),
160            latest,
161            has_update,
162        }
163    }
164
165    /// Returns the current version that was compared against, if any was provided.
166    pub fn current_version(&self) -> Option<&str> {
167        self.current_version.as_deref()
168    }
169
170    /// Returns a reference to the latest release from GitHub.
171    pub fn latest(&self) -> &Release {
172        &self.latest
173    }
174
175    /// Returns `true` if an update is recommended.
176    ///
177    /// This is `true` when:
178    /// - No `current_version` was set (any release is "new"), or
179    /// - The latest version is strictly greater than the current version
180    ///   (compared via [`semver`] when both parse, otherwise lexicographically).
181    #[doc(alias = "update_available")]
182    #[inline]
183    pub fn has_update(&self) -> bool {
184        self.has_update
185    }
186}
187
188/// Shared configuration for update checks — contains no I/O logic.
189///
190/// Both the async and blocking update checkers use this internally.
191#[derive(bon::Builder)]
192#[builder(on(String, into))]
193pub struct UpdateConfig {
194    /// GitHub repository owner (e.g. `"rust-lang"`).
195    pub(crate) owner: String,
196
197    /// GitHub repository name (e.g. `"rust"`).
198    pub(crate) repo: String,
199
200    /// Current version to compare against.
201    pub(crate) current_version: Option<String>,
202
203    /// Optional personal access token for higher rate limits.
204    #[cfg(feature = "github-extra")]
205    pub(crate) token: Option<String>,
206
207    /// Base URL for the GitHub API. Defaults to `https://api.github.com`.
208    #[cfg(all(feature = "github-extra", not(feature = "multi-server")))]
209    pub(crate) base_url: Option<String>,
210
211    /// Base URLs for the GitHub API. Defaults to `https://api.github.com` only.
212    #[cfg(feature = "multi-server")]
213    // #[builder(with = |s: String| vec![s], default)]
214    #[builder(default = vec!["https://api.github.com".into()])]
215    pub(crate) base_urls: Vec<String>,
216
217    /// Custom user-agent header value.
218    #[cfg(feature = "github-extra")]
219    pub(crate) user_agent: Option<String>,
220}
221
222#[cfg(all(feature = "github-extra", feature = "multi-server"))]
223impl<S: State> UpdateConfigBuilder<S> {
224    pub fn base_url(self, url: String) -> UpdateConfigBuilder<SetBaseUrls<S>>
225    where
226        S::BaseUrls: IsUnset,
227    {
228        self.base_urls(vec![url])
229    }
230
231    pub fn maybe_base_url(self, url: Option<String>) -> UpdateConfigBuilder<SetBaseUrls<S>>
232    where
233        S::BaseUrls: IsUnset,
234    {
235        self.maybe_base_urls(url.map(|s| vec![s]))
236    }
237}
238
239impl UpdateConfig {
240    /// Build nyquest [`ClientBuilder`](nyquest::ClientBuilder)s configured
241    /// for GitHub API requests.
242    ///
243    /// ## Returns
244    /// Fortunately, returning `impl Iterator<Item=ClientBuilder>` doesn't increase binary size compared to `ClientBuilder`.
245    #[inline(always)]
246    pub(crate) fn client_builder(&self) -> impl Iterator<Item = nyquest::ClientBuilder> {
247        let it = cfg_select! {
248            feature = "multi-server" => self.base_urls.iter(),
249            feature = "github-extra" => {{
250                // #[builder(default = vec!["https://api.github.com".into()])] would be larger
251                // let base = unsafe { self.base_urls.get_unchecked(0) }.as_ref();
252                // let base = self.base_urls.get(0).map_or("https://api.github.com", |s| s);
253                let base = self.base_url.as_deref().unwrap_or("https://api.github.com");
254                [base].into_iter()
255            }}
256            _ => ["https://api.github.com"].into_iter()
257        };
258
259        let default_ua = concat!("ib-update/", env!("CARGO_PKG_VERSION"));
260        let ua = cfg_select! {
261            // Size +0.5/0 KiB
262            feature = "github-extra" => self.user_agent.as_deref().unwrap_or(&default_ua),
263            _ => default_ua,
264        };
265
266        it.map(move |base| {
267            #[allow(unused_mut)]
268            let mut builder = nyquest::ClientBuilder::default()
269                .base_url(base)
270                .user_agent(ua);
271
272            // Size +1/3 KiB
273            #[cfg(feature = "github-extra")]
274            if let Some(ref token) = self.token {
275                builder = builder.with_header("Authorization", format!("Bearer {token}"));
276            }
277
278            builder
279        })
280    }
281
282    /// The API path for fetching the latest release.
283    ///
284    /// - 404 if no release yet.
285    ///
286    /// Reference: [REST API endpoints for releases - GitHub Docs](https://docs.github.com/en/rest/releases/releases)
287    pub(crate) fn releases_latest_path(&self) -> String {
288        format!("repos/{}/{}/releases/latest", self.owner, self.repo)
289    }
290
291    /// The API path for listing all releases.
292    ///
293    /// - Empty if no releases yet.
294    ///
295    /// Reference: [List releases - GitHub Docs](https://docs.github.com/en/rest/releases/releases#list-releases)
296    pub(crate) fn releases_path(&self, per_page: u8) -> String {
297        format!(
298            "repos/{}/{}/releases?per_page={per_page}",
299            self.owner, self.repo
300        )
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    /// Test repository owner.
307    pub const TEST_OWNER: &str = "Chaoses-Ib";
308    /// Test repository name.
309    pub const TEST_REPO: &str = "IbEverythingExt";
310}