ferrous_forge/rust_version/
github.rs1use crate::{Error, Result};
4use chrono::{DateTime, Utc};
5use reqwest::Client;
6use semver::Version;
7use serde::{Deserialize, Serialize};
8
9const GITHUB_API_BASE: &str = "https://api.github.com";
10const RUST_REPO_OWNER: &str = "rust-lang";
11const RUST_REPO_NAME: &str = "rust";
12
13fn default_version() -> Version {
15 Version::new(0, 0, 0)
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct GitHubRelease {
21 pub id: u64,
23 pub tag_name: String,
25 pub name: String,
27 pub body: String,
29 pub draft: bool,
31 pub prerelease: bool,
33 pub created_at: DateTime<Utc>,
35 pub published_at: Option<DateTime<Utc>>,
37 pub html_url: String,
39 #[serde(skip, default = "default_version")]
41 pub version: Version,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Author {
47 pub login: String,
49 pub id: u64,
51}
52
53pub struct GitHubClient {
55 client: Client,
56 auth_token: Option<String>,
57}
58
59impl GitHubClient {
60 pub fn new(auth_token: Option<String>) -> Result<Self> {
66 let client = Client::builder()
67 .timeout(std::time::Duration::from_secs(30))
68 .user_agent(format!("ferrous-forge/{}", env!("CARGO_PKG_VERSION")))
69 .build()
70 .map_err(|e| Error::network(format!("Failed to create HTTP client: {}", e)))?;
71
72 Ok(Self { client, auth_token })
73 }
74
75 pub async fn get_latest_release(&self) -> Result<GitHubRelease> {
82 let url = format!(
83 "{}/repos/{}/{}/releases/latest",
84 GITHUB_API_BASE, RUST_REPO_OWNER, RUST_REPO_NAME
85 );
86
87 let response = self.make_github_request(&url).await?;
88 self.check_response_status(&response)?;
89
90 let mut release: GitHubRelease = response
91 .json()
92 .await
93 .map_err(|e| Error::parse(format!("Failed to parse release JSON: {}", e)))?;
94
95 release.version = self.parse_version_from_tag(&release.tag_name)?;
97
98 Ok(release)
99 }
100
101 async fn make_github_request(&self, url: &str) -> Result<reqwest::Response> {
103 let mut request = self
104 .client
105 .get(url)
106 .header("Accept", "application/vnd.github.v3+json");
107
108 if let Some(token) = &self.auth_token {
109 request = request.header("Authorization", format!("token {}", token));
110 }
111
112 request
113 .send()
114 .await
115 .map_err(|e| Error::network(format!("Failed to fetch from GitHub: {}", e)))
116 }
117
118 fn check_response_status(&self, response: &reqwest::Response) -> Result<()> {
120 if response.status() == 429 {
121 let retry_after = response
122 .headers()
123 .get("X-RateLimit-Reset")
124 .and_then(|v| v.to_str().ok())
125 .and_then(|s| s.parse::<u64>().ok())
126 .unwrap_or(60);
127
128 return Err(Error::rate_limited(retry_after));
129 }
130
131 if !response.status().is_success() {
132 return Err(Error::network(format!(
133 "GitHub API returned status: {}",
134 response.status()
135 )));
136 }
137
138 Ok(())
139 }
140
141 pub async fn get_releases(&self, count: usize) -> Result<Vec<GitHubRelease>> {
148 let url = format!(
149 "{}/repos/{}/{}/releases?per_page={}",
150 GITHUB_API_BASE, RUST_REPO_OWNER, RUST_REPO_NAME, count
151 );
152
153 let response = self.make_github_request(&url).await?;
154 self.check_response_status(&response)?;
155
156 let mut releases: Vec<GitHubRelease> = response
157 .json()
158 .await
159 .map_err(|e| Error::parse(format!("Failed to parse releases JSON: {}", e)))?;
160
161 for release in &mut releases {
163 release.version = self.parse_version_from_tag(&release.tag_name)?;
164 }
165
166 Ok(releases.into_iter().filter(|r| !r.prerelease).collect())
168 }
169
170 pub async fn get_release_by_tag(&self, tag: &str) -> Result<GitHubRelease> {
178 let url = format!(
179 "{}/repos/{}/{}/releases/tags/{}",
180 GITHUB_API_BASE, RUST_REPO_OWNER, RUST_REPO_NAME, tag
181 );
182
183 let response = self.make_github_request(&url).await?;
184
185 if response.status() == 404 {
186 return Err(Error::network(format!("Release '{}' not found", tag)));
187 }
188
189 self.check_response_status(&response)?;
190
191 let mut release: GitHubRelease = response
192 .json()
193 .await
194 .map_err(|e| Error::parse(format!("Failed to parse release JSON: {}", e)))?;
195
196 release.version = self.parse_version_from_tag(&release.tag_name)?;
198
199 Ok(release)
200 }
201
202 fn parse_version_from_tag(&self, tag: &str) -> Result<Version> {
204 let version_str = tag.strip_prefix('v').unwrap_or(tag);
205 Version::parse(version_str)
206 .map_err(|e| Error::parse(format!("Failed to parse version '{}': {}", tag, e)))
207 }
208}
209
210#[cfg(test)]
211#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn test_parse_version_from_tag() {
217 let client = GitHubClient::new(None).unwrap();
218
219 assert_eq!(
220 client.parse_version_from_tag("1.90.0").unwrap(),
221 Version::new(1, 90, 0)
222 );
223
224 assert_eq!(
225 client.parse_version_from_tag("v1.90.0").unwrap(),
226 Version::new(1, 90, 0)
227 );
228 }
229
230 #[tokio::test]
231 #[ignore] async fn test_get_latest_release() -> Result<()> {
233 let client = GitHubClient::new(None)?;
234 let release = client.get_latest_release().await?;
235
236 assert!(!release.tag_name.is_empty());
237 assert!(release.version.major >= 1);
238 Ok(())
239 }
240}