ferrous_forge/rust_version/
github.rs

1//! GitHub API client for fetching Rust releases
2
3use 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
13/// Default version for deserialization
14fn default_version() -> Version {
15    Version::new(0, 0, 0)
16}
17
18/// GitHub release information
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct GitHubRelease {
21    /// Release ID
22    pub id: u64,
23    /// Tag name (e.g., "1.90.0")
24    pub tag_name: String,
25    /// Release name
26    pub name: String,
27    /// Release description/notes
28    pub body: String,
29    /// Is this a draft?
30    pub draft: bool,
31    /// Is this a prerelease?
32    pub prerelease: bool,
33    /// Creation date
34    pub created_at: DateTime<Utc>,
35    /// Publication date
36    pub published_at: Option<DateTime<Utc>>,
37    /// HTML URL to the release page
38    pub html_url: String,
39    /// Parsed semantic version
40    #[serde(skip, default = "default_version")]
41    pub version: Version,
42}
43
44/// Simplified author information
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Author {
47    pub login: String,
48    pub id: u64,
49}
50
51/// GitHub API client
52pub struct GitHubClient {
53    client: Client,
54    auth_token: Option<String>,
55}
56
57impl GitHubClient {
58    /// Create a new GitHub client
59    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    /// Get the latest stable release
73    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        // Check for rate limiting
90        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        // Parse version from tag
112        release.version = self.parse_version_from_tag(&release.tag_name)?;
113        
114        Ok(release)
115    }
116    
117    /// Get multiple recent releases
118    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        // Parse versions for all releases
156        for release in &mut releases {
157            release.version = self.parse_version_from_tag(&release.tag_name)?;
158        }
159        
160        // Filter out pre-releases from stable channel
161        Ok(releases
162            .into_iter()
163            .filter(|r| !r.prerelease)
164            .collect())
165    }
166    
167    /// Parse semantic version from tag name
168    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] // Requires network access
196    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}