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    /// GitHub username
48    pub login: String,
49    /// GitHub user ID
50    pub id: u64,
51}
52
53/// GitHub API client
54pub struct GitHubClient {
55    client: Client,
56    auth_token: Option<String>,
57}
58
59impl GitHubClient {
60    /// Create a new GitHub client
61    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    /// Get the latest stable release
75    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        // Check for rate limiting
92        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        // Parse version from tag
114        release.version = self.parse_version_from_tag(&release.tag_name)?;
115        
116        Ok(release)
117    }
118    
119    /// Get multiple recent releases
120    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        // Parse versions for all releases
158        for release in &mut releases {
159            release.version = self.parse_version_from_tag(&release.tag_name)?;
160        }
161        
162        // Filter out pre-releases from stable channel
163        Ok(releases
164            .into_iter()
165            .filter(|r| !r.prerelease)
166            .collect())
167    }
168    
169    /// Parse semantic version from tag name
170    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] // Requires network access
198    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}