Skip to main content

corundum_framework_update/
release.rs

1//! Where a binary's releases are, and which is the newest.
2
3use std::time::Duration;
4
5use serde::Deserialize;
6
7use crate::{Error, Result};
8
9/// How long one request may take before it counts as failed. Downloads have their own,
10/// longer budget.
11const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
12
13/// Where a binary's releases are published: a GitHub repository whose releases are tagged
14/// `<prefix><version>`, and — for the `update` command — the file in them.
15#[derive(Clone, Debug)]
16pub struct ReleaseSource {
17    owner: String,
18    repo: String,
19    tag_prefix: String,
20    api_base: String,
21    asset: Option<String>,
22    cargo_crate: Option<String>,
23}
24
25impl ReleaseSource {
26    /// The releases of `github.com/<owner>/<repo>`, tagged `v<version>`.
27    pub fn github(owner: impl Into<String>, repo: impl Into<String>) -> ReleaseSource {
28        ReleaseSource {
29            owner: owner.into(),
30            repo: repo.into(),
31            tag_prefix: "v".to_owned(),
32            api_base: "https://api.github.com".to_owned(),
33            asset: None,
34            cargo_crate: None,
35        }
36    }
37
38    /// Release tags are `<prefix><version>` rather than `v<version>`, e.g. `cli-v` in a
39    /// repository that also tags a desktop app.
40    pub fn tag_prefix(mut self, prefix: impl Into<String>) -> ReleaseSource {
41        self.tag_prefix = prefix.into();
42        self
43    }
44
45    /// Ask another GitHub API root than `https://api.github.com`: for fixtures.
46    pub fn api_base(mut self, base: impl Into<String>) -> ReleaseSource {
47        self.api_base = base.into();
48        self
49    }
50
51    /// The binary is published as `<name>-<os>-<arch>` (`.exe` on Windows): what `update`
52    /// downloads. Without it, `update` only reports.
53    pub fn asset(mut self, name: impl Into<String>) -> ReleaseSource {
54        self.asset = Some(name.into());
55        self
56    }
57
58    /// The crate `cargo install` installs this binary from, for the advice `update` gives
59    /// a binary it will not replace.
60    pub fn cargo_crate(mut self, name: impl Into<String>) -> ReleaseSource {
61        self.cargo_crate = Some(name.into());
62        self
63    }
64
65    /// `<owner>/<repo>`.
66    pub fn repository(&self) -> String {
67        format!("{}/{}", self.owner, self.repo)
68    }
69
70    /// The release file's name without the platform ([`asset`](Self::asset)), if named.
71    pub fn asset_base(&self) -> Option<&str> {
72        self.asset.as_deref()
73    }
74
75    pub(crate) fn crate_name(&self) -> Option<&str> {
76        self.cargo_crate.as_deref()
77    }
78
79    /// The repository's newest releases. Not `releases/latest`: a repository that tags
80    /// several binaries (`cli-v…`, `desktop-v…`) has one "latest" among all of them.
81    fn releases_url(&self) -> String {
82        format!(
83            "{}/repos/{}/{}/releases?per_page=100",
84            self.api_base.trim_end_matches('/'),
85            self.owner,
86            self.repo
87        )
88    }
89}
90
91/// One published release.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct Release {
94    /// Its version, the tag without its prefix.
95    pub version: semver::Version,
96    /// Its web page, where its notes are.
97    pub url: String,
98    /// Its files.
99    pub assets: Vec<Asset>,
100}
101
102/// One file of a release.
103#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
104pub struct Asset {
105    /// The file's name.
106    pub name: String,
107    /// Where it downloads from.
108    #[serde(rename = "browser_download_url")]
109    pub download_url: String,
110}
111
112/// The part of GitHub's release object this reads.
113#[derive(Debug, Deserialize)]
114pub(crate) struct GithubRelease {
115    pub(crate) tag_name: String,
116    pub(crate) html_url: String,
117    #[serde(default)]
118    pub(crate) draft: bool,
119    #[serde(default)]
120    pub(crate) prerelease: bool,
121    #[serde(default)]
122    pub(crate) assets: Vec<Asset>,
123}
124
125/// An HTTP client that names itself `user_agent` (GitHub refuses requests without one).
126pub fn client(user_agent: &str) -> Result<reqwest::Client> {
127    reqwest::Client::builder()
128        .user_agent(user_agent)
129        .timeout(REQUEST_TIMEOUT)
130        .build()
131        .map_err(|e| Error::Http(format!("no HTTP client: {e}")))
132}
133
134/// Ask GitHub for `source`'s newest release.
135pub async fn latest_release(client: &reqwest::Client, source: &ReleaseSource) -> Result<Release> {
136    let url = source.releases_url();
137    let releases: Vec<GithubRelease> = client
138        .get(&url)
139        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
140        .send()
141        .await
142        .and_then(reqwest::Response::error_for_status)
143        .map_err(|e| Error::Http(format!("asking {url}: {e}")))?
144        .json()
145        .await
146        .map_err(|e| Error::Http(format!("reading the releases: {e}")))?;
147    newest(releases, &source.tag_prefix).ok_or_else(|| {
148        Error::NoRelease(format!(
149            "{} has no published release tagged {}<version>",
150            source.repository(),
151            source.tag_prefix
152        ))
153    })
154}
155
156/// The newest published, non-prerelease release tagged `<prefix><semver>`. Tags of the
157/// repository's other binaries, drafts, prereleases and tags that are not versions are
158/// skipped.
159pub(crate) fn newest(
160    releases: impl IntoIterator<Item = GithubRelease>,
161    prefix: &str,
162) -> Option<Release> {
163    releases
164        .into_iter()
165        .filter(|r| !r.draft && !r.prerelease)
166        .filter_map(|r| {
167            let version = semver::Version::parse(r.tag_name.strip_prefix(prefix)?).ok()?;
168            version.pre.is_empty().then_some(Release {
169                version,
170                url: r.html_url,
171                assets: r.assets,
172            })
173        })
174        .max_by(|a, b| a.version.cmp(&b.version))
175}
176
177#[cfg(test)]
178pub(crate) mod tests {
179    use super::*;
180
181    pub(crate) fn release(tag: &str) -> GithubRelease {
182        GithubRelease {
183            tag_name: tag.to_owned(),
184            html_url: format!("https://example.invalid/{tag}"),
185            draft: false,
186            prerelease: false,
187            assets: Vec::new(),
188        }
189    }
190
191    #[test]
192    fn the_newest_release_is_the_highest_version_with_the_prefix() {
193        // sapphire-journal tags its CLI and its desktop app in one repository, and the
194        // desktop app's release can be the newest of all.
195        let releases = || {
196            [
197                release("desktop-v0.1.0"),
198                release("cli-v0.12.0"),
199                release("cli-v0.9.3"),
200                release("cli-v0.11.0"),
201            ]
202        };
203        let info = newest(releases(), "cli-v").unwrap();
204        assert_eq!(info.version.to_string(), "0.12.0");
205        assert_eq!(info.url, "https://example.invalid/cli-v0.12.0");
206        assert_eq!(
207            newest(releases(), "desktop-v").unwrap().version.to_string(),
208            "0.1.0"
209        );
210        // `v` does not reach into `desktop-v…`, nor `cli-v` into `sapphire-agent-cli-v…`.
211        assert_eq!(newest(releases(), "v"), None);
212        assert_eq!(
213            newest([release("sapphire-agent-cli-v0.8.0")], "cli-v"),
214            None
215        );
216    }
217
218    #[test]
219    fn drafts_prereleases_and_tags_that_are_not_versions_are_skipped() {
220        let mut draft = release("v0.22.0");
221        draft.draft = true;
222        let mut flagged = release("v0.21.0");
223        flagged.prerelease = true;
224        let releases = [
225            draft,
226            flagged,
227            release("v0.21.0-rc.1"),
228            release("vnext"),
229            release("v0.20.0"),
230        ];
231        assert_eq!(newest(releases, "v").unwrap().version.to_string(), "0.20.0");
232    }
233}