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