use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct ReleaseAsset {
pub name: String,
pub browser_download_url: String,
}
#[derive(Debug, Deserialize)]
struct ReleaseResponse {
#[allow(dead_code)]
tag_name: Option<String>,
assets: Vec<ReleaseAsset>,
}
#[derive(Debug)]
pub enum GithubApiError {
NotFound,
RateLimited,
Network(reqwest::Error),
Parse(String),
}
impl std::fmt::Display for GithubApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GithubApiError::NotFound => write!(f, "release not found"),
GithubApiError::RateLimited => write!(f, "GitHub API rate limited or unauthorized"),
GithubApiError::Network(e) => write!(f, "network error: {e}"),
GithubApiError::Parse(s) => write!(f, "JSON parse error: {s}"),
}
}
}
impl std::error::Error for GithubApiError {}
pub async fn fetch_release_assets(
client: &reqwest::Client,
base_url: &str,
owner: &str,
repo: &str,
tag: &str,
token: Option<&str>,
) -> Result<Vec<ReleaseAsset>, GithubApiError> {
let url = format!("{base_url}/repos/{owner}/{repo}/releases/tags/{tag}");
let mut req = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header(
"User-Agent",
"cargo-fresh (https://github.com/jenkinpan/cargo-fresh)",
);
if let Some(t) = token {
req = req.header("Authorization", format!("Bearer {t}"));
}
let resp = req.send().await.map_err(GithubApiError::Network)?;
match resp.status().as_u16() {
200 => {
let body: ReleaseResponse = resp
.json()
.await
.map_err(|e| GithubApiError::Parse(e.to_string()))?;
Ok(body.assets)
}
404 => Err(GithubApiError::NotFound),
401 | 403 | 429 => Err(GithubApiError::RateLimited),
other => Err(GithubApiError::Parse(format!("unexpected status {other}"))),
}
}
pub fn parse_owner_repo(url: &str) -> Option<(String, String)> {
let trimmed = url.trim_end_matches('/');
let stripped = trimmed
.strip_prefix("https://github.com/")
.or_else(|| trimmed.strip_prefix("http://github.com/"))?;
let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
let mut parts = stripped.split('/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
if owner.is_empty() || repo.is_empty() {
return None;
}
Some((owner, repo))
}
pub fn match_winning_asset<'a>(
assets: &'a [ReleaseAsset],
expected: &[String],
) -> Option<&'a ReleaseAsset> {
assets.iter().find(|a| expected.iter().any(|e| e == &a.name))
}