corundum_framework_update/
release.rs1use std::time::Duration;
4
5use serde::Deserialize;
6
7use crate::{Error, Result};
8
9const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
12
13#[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 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 pub fn tag_prefix(mut self, prefix: impl Into<String>) -> ReleaseSource {
41 self.tag_prefix = prefix.into();
42 self
43 }
44
45 pub fn api_base(mut self, base: impl Into<String>) -> ReleaseSource {
47 self.api_base = base.into();
48 self
49 }
50
51 pub fn asset(mut self, name: impl Into<String>) -> ReleaseSource {
54 self.asset = Some(name.into());
55 self
56 }
57
58 pub fn cargo_crate(mut self, name: impl Into<String>) -> ReleaseSource {
61 self.cargo_crate = Some(name.into());
62 self
63 }
64
65 pub fn repository(&self) -> String {
67 format!("{}/{}", self.owner, self.repo)
68 }
69
70 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 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#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct Release {
94 pub version: semver::Version,
96 pub url: String,
98 pub assets: Vec<Asset>,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
104pub struct Asset {
105 pub name: String,
107 #[serde(rename = "browser_download_url")]
109 pub download_url: String,
110}
111
112#[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
125pub 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
134pub 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
156pub(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 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 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}