Skip to main content

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    ///
62    /// # Errors
63    ///
64    /// Returns an error if the HTTP client cannot be constructed.
65    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    /// Get the latest stable release
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the GitHub API request fails, the response status
80    /// indicates an error, or the response JSON cannot be parsed.
81    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        // Parse version from tag
96        release.version = self.parse_version_from_tag(&release.tag_name)?;
97
98        Ok(release)
99    }
100
101    /// Make a GitHub API request with authentication
102    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    /// Check response status and handle rate limiting
119    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    /// Get multiple recent releases
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the GitHub API request fails, the response status
146    /// indicates an error, or the response JSON cannot be parsed.
147    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        // Parse versions for all releases
162        for release in &mut releases {
163            release.version = self.parse_version_from_tag(&release.tag_name)?;
164        }
165
166        // Filter out pre-releases from stable channel
167        Ok(releases.into_iter().filter(|r| !r.prerelease).collect())
168    }
169
170    /// Parse semantic version from tag name
171    fn parse_version_from_tag(&self, tag: &str) -> Result<Version> {
172        let version_str = tag.strip_prefix('v').unwrap_or(tag);
173        Version::parse(version_str)
174            .map_err(|e| Error::parse(format!("Failed to parse version '{}': {}", tag, e)))
175    }
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_parse_version_from_tag() {
185        let client = GitHubClient::new(None).unwrap();
186
187        assert_eq!(
188            client.parse_version_from_tag("1.90.0").unwrap(),
189            Version::new(1, 90, 0)
190        );
191
192        assert_eq!(
193            client.parse_version_from_tag("v1.90.0").unwrap(),
194            Version::new(1, 90, 0)
195        );
196    }
197
198    #[tokio::test]
199    #[ignore] // Requires network access
200    async fn test_get_latest_release() -> Result<()> {
201        let client = GitHubClient::new(None)?;
202        let release = client.get_latest_release().await?;
203
204        assert!(!release.tag_name.is_empty());
205        assert!(release.version.major >= 1);
206        Ok(())
207    }
208}