use crate::{Error, Result};
use chrono::{DateTime, Utc};
use reqwest::Client;
use semver::Version;
use serde::{Deserialize, Serialize};
const GITHUB_API_BASE: &str = "https://api.github.com";
const RUST_REPO_OWNER: &str = "rust-lang";
const RUST_REPO_NAME: &str = "rust";
fn default_version() -> Version {
Version::new(0, 0, 0)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubRelease {
pub id: u64,
pub tag_name: String,
pub name: String,
pub body: String,
pub draft: bool,
pub prerelease: bool,
pub created_at: DateTime<Utc>,
pub published_at: Option<DateTime<Utc>>,
pub html_url: String,
#[serde(skip, default = "default_version")]
pub version: Version,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Author {
pub login: String,
pub id: u64,
}
pub struct GitHubClient {
client: Client,
auth_token: Option<String>,
}
impl GitHubClient {
pub fn new(auth_token: Option<String>) -> Result<Self> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.user_agent(format!("ferrous-forge/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| Error::network(format!("Failed to create HTTP client: {}", e)))?;
Ok(Self { client, auth_token })
}
pub async fn get_latest_release(&self) -> Result<GitHubRelease> {
let url = format!(
"{}/repos/{}/{}/releases/latest",
GITHUB_API_BASE, RUST_REPO_OWNER, RUST_REPO_NAME
);
let response = self.make_github_request(&url).await?;
self.check_response_status(&response)?;
let mut release: GitHubRelease = response
.json()
.await
.map_err(|e| Error::parse(format!("Failed to parse release JSON: {}", e)))?;
release.version = self.parse_version_from_tag(&release.tag_name)?;
Ok(release)
}
async fn make_github_request(&self, url: &str) -> Result<reqwest::Response> {
let mut request = self
.client
.get(url)
.header("Accept", "application/vnd.github.v3+json");
if let Some(token) = &self.auth_token {
request = request.header("Authorization", format!("token {}", token));
}
request
.send()
.await
.map_err(|e| Error::network(format!("Failed to fetch from GitHub: {}", e)))
}
fn check_response_status(&self, response: &reqwest::Response) -> Result<()> {
if response.status() == 429 {
let retry_after = response
.headers()
.get("X-RateLimit-Reset")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(60);
return Err(Error::rate_limited(retry_after));
}
if !response.status().is_success() {
return Err(Error::network(format!(
"GitHub API returned status: {}",
response.status()
)));
}
Ok(())
}
pub async fn get_releases(&self, count: usize) -> Result<Vec<GitHubRelease>> {
let url = format!(
"{}/repos/{}/{}/releases?per_page={}",
GITHUB_API_BASE, RUST_REPO_OWNER, RUST_REPO_NAME, count
);
let response = self.make_github_request(&url).await?;
self.check_response_status(&response)?;
let mut releases: Vec<GitHubRelease> = response
.json()
.await
.map_err(|e| Error::parse(format!("Failed to parse releases JSON: {}", e)))?;
for release in &mut releases {
release.version = self.parse_version_from_tag(&release.tag_name)?;
}
Ok(releases.into_iter().filter(|r| !r.prerelease).collect())
}
pub async fn get_release_by_tag(&self, tag: &str) -> Result<GitHubRelease> {
let url = format!(
"{}/repos/{}/{}/releases/tags/{}",
GITHUB_API_BASE, RUST_REPO_OWNER, RUST_REPO_NAME, tag
);
let response = self.make_github_request(&url).await?;
if response.status() == 404 {
return Err(Error::network(format!("Release '{}' not found", tag)));
}
self.check_response_status(&response)?;
let mut release: GitHubRelease = response
.json()
.await
.map_err(|e| Error::parse(format!("Failed to parse release JSON: {}", e)))?;
release.version = self.parse_version_from_tag(&release.tag_name)?;
Ok(release)
}
fn parse_version_from_tag(&self, tag: &str) -> Result<Version> {
let version_str = tag.strip_prefix('v').unwrap_or(tag);
Version::parse(version_str)
.map_err(|e| Error::parse(format!("Failed to parse version '{}': {}", tag, e)))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn test_parse_version_from_tag() {
let client = GitHubClient::new(None).unwrap();
assert_eq!(
client.parse_version_from_tag("1.90.0").unwrap(),
Version::new(1, 90, 0)
);
assert_eq!(
client.parse_version_from_tag("v1.90.0").unwrap(),
Version::new(1, 90, 0)
);
}
#[tokio::test]
#[ignore] async fn test_get_latest_release() -> Result<()> {
let client = GitHubClient::new(None)?;
let release = client.get_latest_release().await?;
assert!(!release.tag_name.is_empty());
assert!(release.version.major >= 1);
Ok(())
}
}