xtask_lib/tasks/util/
github.rs1use std::path::Path;
19
20use anyhow::{Context, Result};
21use serde::Deserialize;
22
23fn client() -> Result<reqwest::blocking::Client> {
28 let mut builder = reqwest::blocking::Client::builder().user_agent("cargo-xtask/cadmus");
29
30 if let Some(token) = std::env::var("GH_TOKEN")
31 .ok()
32 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
33 {
34 let mut auth = reqwest::header::HeaderMap::new();
35 auth.insert(
36 reqwest::header::AUTHORIZATION,
37 reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
38 .context("invalid token value for Authorization header")?,
39 );
40 builder = builder.default_headers(auth);
41 }
42
43 builder.build().context("failed to build HTTP client")
44}
45
46#[derive(Debug, Deserialize)]
48pub struct Asset {
49 pub browser_download_url: String,
51 pub name: String,
53 pub digest: Option<String>,
55}
56
57impl Asset {
58 pub fn sha256(&self) -> Option<&str> {
63 self.digest
64 .as_deref()
65 .and_then(|d| d.strip_prefix("sha256:"))
66 }
67}
68
69#[derive(Debug, Deserialize)]
71struct Release {
72 assets: Vec<Asset>,
73}
74
75pub fn fetch_release_asset(repo: &str, tag: &str, asset_name: &str) -> Result<Asset> {
85 let url = format!("https://api.github.com/repos/{repo}/releases/tags/{tag}");
86 fetch_release_from_url(&url)?
87 .assets
88 .into_iter()
89 .find(|a| a.name == asset_name)
90 .with_context(|| format!("asset '{asset_name}' not found in release {tag} of {repo}"))
91}
92
93#[allow(dead_code)]
109pub fn fetch_latest_release_asset(repo: &str, asset_name: &str) -> Result<Asset> {
110 let url = format!("https://api.github.com/repos/{repo}/releases/latest");
111 fetch_release_from_url(&url)?
112 .assets
113 .into_iter()
114 .find(|a| a.name == asset_name)
115 .with_context(|| format!("asset '{asset_name}' not found in latest release of {repo}"))
116}
117
118#[allow(dead_code)]
134pub fn fetch_latest_release_asset_by_prefix(repo: &str, prefix: &str) -> Result<Asset> {
135 let url = format!("https://api.github.com/repos/{repo}/releases/latest");
136 fetch_release_from_url(&url)?
137 .assets
138 .into_iter()
139 .find(|a| a.name.starts_with(prefix))
140 .with_context(|| {
141 format!("no asset with prefix '{prefix}' found in latest release of {repo}")
142 })
143}
144
145pub fn download_asset(asset: &Asset, dest: &Path) -> Result<()> {
155 if let Some(parent) = dest.parent() {
156 std::fs::create_dir_all(parent)
157 .with_context(|| format!("failed to create parent directory for {}", dest.display()))?;
158 }
159
160 let bytes = client()?
161 .get(&asset.browser_download_url)
162 .send()
163 .with_context(|| format!("HTTP request failed for {}", asset.browser_download_url))?
164 .error_for_status()
165 .with_context(|| {
166 format!(
167 "server returned error status for {}",
168 asset.browser_download_url
169 )
170 })?
171 .bytes()
172 .with_context(|| {
173 format!(
174 "failed to read response body from {}",
175 asset.browser_download_url
176 )
177 })?;
178
179 std::fs::write(dest, &bytes)
180 .with_context(|| format!("failed to write downloaded file to {}", dest.display()))
181}
182
183fn fetch_release_from_url(url: &str) -> Result<Release> {
184 client()?
185 .get(url)
186 .send()
187 .with_context(|| format!("HTTP request failed for {url}"))?
188 .error_for_status()
189 .with_context(|| format!("server returned error status for {url}"))?
190 .json()
191 .with_context(|| format!("failed to parse GitHub API response from {url}"))
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn sample_release_json(name: &str, digest: Option<&str>) -> String {
199 let digest_field = match digest {
200 Some(d) => format!(r#", "digest": "{d}""#),
201 None => String::new(),
202 };
203 format!(
204 r#"{{
205 "assets": [
206 {{
207 "browser_download_url": "https://github.com/example/releases/download/v1.0/{name}",
208 "name": "{name}"{digest_field}
209 }}
210 ]
211 }}"#
212 )
213 }
214
215 #[test]
216 fn asset_sha256_strips_prefix() {
217 let asset = Asset {
218 browser_download_url: String::new(),
219 name: String::new(),
220 digest: Some("sha256:deadbeef".to_owned()),
221 };
222 assert_eq!(asset.sha256(), Some("deadbeef"));
223 }
224
225 #[test]
226 fn asset_sha256_returns_none_when_absent() {
227 let asset = Asset {
228 browser_download_url: String::new(),
229 name: String::new(),
230 digest: None,
231 };
232 assert!(asset.sha256().is_none());
233 }
234
235 #[test]
236 fn asset_sha256_returns_none_for_unexpected_prefix() {
237 let asset = Asset {
238 browser_download_url: String::new(),
239 name: String::new(),
240 digest: Some("md5:deadbeef".to_owned()),
241 };
242 assert!(asset.sha256().is_none());
243 }
244
245 #[test]
246 fn release_deserializes_asset_with_digest() {
247 let json = sample_release_json("foo.tgz", Some("sha256:abc123"));
248 let release: Release = serde_json::from_str(&json).unwrap();
249 assert_eq!(release.assets.len(), 1);
250 assert_eq!(release.assets[0].name, "foo.tgz");
251 assert_eq!(release.assets[0].sha256(), Some("abc123"));
252 }
253
254 #[test]
255 fn release_deserializes_asset_without_digest() {
256 let json = sample_release_json("foo.tgz", None);
257 let release: Release = serde_json::from_str(&json).unwrap();
258 assert!(release.assets[0].sha256().is_none());
259 }
260
261 #[test]
262 fn release_deserializes_download_url() {
263 let json = sample_release_json("foo.tgz", None);
264 let release: Release = serde_json::from_str(&json).unwrap();
265 assert!(release.assets[0].browser_download_url.contains("foo.tgz"));
266 }
267
268 #[test]
269 fn release_deserializes_empty_assets() {
270 let json = r#"{"assets": []}"#;
271 let release: Release = serde_json::from_str(json).unwrap();
272 assert!(release.assets.is_empty());
273 }
274}