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> {
62 let client = Client::builder()
63 .timeout(std::time::Duration::from_secs(30))
64 .user_agent(format!(
65 "ferrous-forge/{}",
66 env!("CARGO_PKG_VERSION")
67 ))
68 .build()
69 .map_err(|e| Error::network(format!("Failed to create HTTP client: {}", e)))?;
70
71 Ok(Self { client, auth_token })
72 }
73
74 pub async fn get_latest_release(&self) -> Result<GitHubRelease> {
76 let url = format!(
77 "{}/repos/{}/{}/releases/latest",
78 GITHUB_API_BASE, RUST_REPO_OWNER, RUST_REPO_NAME
79 );
80
81 let mut request = self.client.get(&url)
82 .header("Accept", "application/vnd.github.v3+json");
83
84 if let Some(token) = &self.auth_token {
85 request = request.header("Authorization", format!("token {}", token));
86 }
87
88 let response = request.send().await
89 .map_err(|e| Error::network(format!("Failed to fetch release: {}", e)))?;
90
91 if response.status() == 429 {
93 let retry_after = response
94 .headers()
95 .get("X-RateLimit-Reset")
96 .and_then(|v| v.to_str().ok())
97 .and_then(|s| s.parse::<u64>().ok())
98 .unwrap_or(60);
99
100 return Err(Error::rate_limited(retry_after));
101 }
102
103 if !response.status().is_success() {
104 return Err(Error::network(format!(
105 "GitHub API returned status: {}",
106 response.status()
107 )));
108 }
109
110 let mut release: GitHubRelease = response.json().await
111 .map_err(|e| Error::parse(format!("Failed to parse release JSON: {}", e)))?;
112
113 release.version = self.parse_version_from_tag(&release.tag_name)?;
115
116 Ok(release)
117 }
118
119 pub async fn get_releases(&self, count: usize) -> Result<Vec<GitHubRelease>> {
121 let url = format!(
122 "{}/repos/{}/{}/releases?per_page={}",
123 GITHUB_API_BASE, RUST_REPO_OWNER, RUST_REPO_NAME, count
124 );
125
126 let mut request = self.client.get(&url)
127 .header("Accept", "application/vnd.github.v3+json");
128
129 if let Some(token) = &self.auth_token {
130 request = request.header("Authorization", format!("token {}", token));
131 }
132
133 let response = request.send().await
134 .map_err(|e| Error::network(format!("Failed to fetch releases: {}", e)))?;
135
136 if response.status() == 429 {
137 let retry_after = response
138 .headers()
139 .get("X-RateLimit-Reset")
140 .and_then(|v| v.to_str().ok())
141 .and_then(|s| s.parse::<u64>().ok())
142 .unwrap_or(60);
143
144 return Err(Error::rate_limited(retry_after));
145 }
146
147 if !response.status().is_success() {
148 return Err(Error::network(format!(
149 "GitHub API returned status: {}",
150 response.status()
151 )));
152 }
153
154 let mut releases: Vec<GitHubRelease> = response.json().await
155 .map_err(|e| Error::parse(format!("Failed to parse releases JSON: {}", e)))?;
156
157 for release in &mut releases {
159 release.version = self.parse_version_from_tag(&release.tag_name)?;
160 }
161
162 Ok(releases
164 .into_iter()
165 .filter(|r| !r.prerelease)
166 .collect())
167 }
168
169 fn parse_version_from_tag(&self, tag: &str) -> Result<Version> {
171 let version_str = tag.strip_prefix('v').unwrap_or(tag);
172 Version::parse(version_str)
173 .map_err(|e| Error::parse(format!("Failed to parse version '{}': {}", tag, e)))
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn test_parse_version_from_tag() {
183 let client = GitHubClient::new(None).unwrap();
184
185 assert_eq!(
186 client.parse_version_from_tag("1.90.0").unwrap(),
187 Version::new(1, 90, 0)
188 );
189
190 assert_eq!(
191 client.parse_version_from_tag("v1.90.0").unwrap(),
192 Version::new(1, 90, 0)
193 );
194 }
195
196 #[tokio::test]
197 #[ignore] async fn test_get_latest_release() {
199 let client = GitHubClient::new(None).unwrap();
200 let release = client.get_latest_release().await.unwrap();
201
202 assert!(!release.tag_name.is_empty());
203 assert!(release.version.major >= 1);
204 }
205}