Skip to main content

turbo_cdn/
github_releases.rs

1// Licensed under the MIT License
2// Copyright (c) 2025 Hal <hal.long@outlook.com>
3
4//! GitHub Releases version listing API with CDN fallback
5//!
6//! This module provides APIs for fetching GitHub releases version lists,
7//! with automatic fallback to jsDelivr's data API when the GitHub API
8//! is rate-limited or unavailable.
9//!
10//! # Features
11//!
12//! - **GitHub API**: Direct access to GitHub releases (requires token for higher rate limits)
13//! - **jsDelivr Fallback**: Automatic fallback to jsDelivr data API (no rate limits)
14//! - **Version Filtering**: Filter pre-releases, drafts, and specific patterns
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use turbo_cdn::github_releases::GitHubReleasesFetcher;
20//!
21//! #[tokio::main]
22//! async fn main() -> turbo_cdn::Result<()> {
23//!     let fetcher = GitHubReleasesFetcher::new();
24//!     
25//!     // Fetch versions with automatic fallback
26//!     let versions = fetcher.fetch_versions("BurntSushi", "ripgrep").await?;
27//!     for version in &versions {
28//!         println!("{}", version);
29//!     }
30//!     
31//!     // Fetch detailed release info
32//!     let releases = fetcher.list_releases("BurntSushi", "ripgrep").await?;
33//!     for release in &releases {
34//!         println!("{} (prerelease: {})", release.tag_name, release.prerelease);
35//!     }
36//!     
37//!     Ok(())
38//! }
39//! ```
40
41use crate::error::{Result, TurboCdnError};
42use serde::{Deserialize, Serialize};
43use std::time::Duration;
44use tracing::{debug, info, warn};
45
46/// Default timeout for API requests
47const API_TIMEOUT: Duration = Duration::from_secs(30);
48
49/// GitHub API base URL
50const GITHUB_API_BASE: &str = "https://api.github.com";
51
52/// jsDelivr data API base URL (fallback, no rate limits)
53const JSDELIVR_DATA_API_BASE: &str = "https://data.jsdelivr.com/v1";
54
55/// Maximum number of releases to fetch per page from GitHub API
56const GITHUB_PER_PAGE: u32 = 100;
57
58/// Release information from GitHub
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ReleaseInfo {
61    /// Release tag name (e.g., "v1.0.0")
62    pub tag_name: String,
63    /// Release name/title
64    pub name: Option<String>,
65    /// Whether this is a pre-release
66    pub prerelease: bool,
67    /// Whether this is a draft
68    pub draft: bool,
69    /// Published date (ISO 8601)
70    pub published_at: Option<String>,
71    /// List of asset names available in this release
72    pub assets: Vec<AssetInfo>,
73}
74
75/// Asset information from a GitHub release
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct AssetInfo {
78    /// Asset file name
79    pub name: String,
80    /// Asset size in bytes
81    pub size: u64,
82    /// Download URL
83    pub browser_download_url: String,
84    /// Content type
85    pub content_type: Option<String>,
86    /// Download count
87    pub download_count: u64,
88}
89
90/// Version information from jsDelivr (simplified)
91#[derive(Debug, Clone, Serialize, Deserialize)]
92struct JsDelivrPackageResponse {
93    /// Package type
94    #[serde(rename = "type")]
95    package_type: Option<String>,
96    /// Package name
97    name: Option<String>,
98    /// Available versions
99    versions: Vec<JsDelivrVersion>,
100}
101
102/// jsDelivr version entry
103#[derive(Debug, Clone, Serialize, Deserialize)]
104struct JsDelivrVersion {
105    /// Version string
106    version: String,
107}
108
109/// Options for filtering releases
110#[derive(Debug, Clone)]
111pub struct FetchOptions {
112    /// Include pre-releases
113    pub include_prereleases: bool,
114    /// Include drafts (requires authentication)
115    pub include_drafts: bool,
116    /// Maximum number of versions to return
117    pub max_versions: Option<usize>,
118    /// GitHub personal access token for higher rate limits
119    pub github_token: Option<String>,
120    /// Request timeout
121    pub timeout: Duration,
122}
123
124impl Default for FetchOptions {
125    fn default() -> Self {
126        Self {
127            include_prereleases: false,
128            include_drafts: false,
129            max_versions: None,
130            github_token: None,
131            timeout: API_TIMEOUT,
132        }
133    }
134}
135
136impl FetchOptions {
137    /// Create new fetch options
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Include pre-releases in results
143    pub fn with_prereleases(mut self, include: bool) -> Self {
144        self.include_prereleases = include;
145        self
146    }
147
148    /// Include drafts in results
149    pub fn with_drafts(mut self, include: bool) -> Self {
150        self.include_drafts = include;
151        self
152    }
153
154    /// Set maximum number of versions to return
155    pub fn with_max_versions(mut self, max: usize) -> Self {
156        self.max_versions = Some(max);
157        self
158    }
159
160    /// Set GitHub token for authentication
161    pub fn with_github_token<S: Into<String>>(mut self, token: S) -> Self {
162        self.github_token = Some(token.into());
163        self
164    }
165
166    /// Set request timeout
167    pub fn with_timeout(mut self, timeout: Duration) -> Self {
168        self.timeout = timeout;
169        self
170    }
171}
172
173/// Source of version data
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum DataSource {
176    /// Data fetched from GitHub API
177    GitHub,
178    /// Data fetched from jsDelivr CDN (fallback)
179    JsDelivr,
180}
181
182impl std::fmt::Display for DataSource {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        match self {
185            DataSource::GitHub => write!(f, "GitHub API"),
186            DataSource::JsDelivr => write!(f, "jsDelivr CDN"),
187        }
188    }
189}
190
191/// Result of a version fetch operation, including data source info
192#[derive(Debug, Clone)]
193pub struct VersionsResult {
194    /// List of version strings
195    pub versions: Vec<String>,
196    /// Where the data came from
197    pub source: DataSource,
198}
199
200/// Result of a releases fetch operation, including data source info
201#[derive(Debug, Clone)]
202pub struct ReleasesResult {
203    /// List of release info
204    pub releases: Vec<ReleaseInfo>,
205    /// Where the data came from
206    pub source: DataSource,
207}
208
209/// Fetcher for GitHub releases with CDN fallback
210///
211/// Provides methods to fetch version lists and release information
212/// from GitHub, with automatic fallback to jsDelivr when GitHub API
213/// is unavailable or rate-limited.
214#[derive(Debug, Clone)]
215pub struct GitHubReleasesFetcher {
216    options: FetchOptions,
217}
218
219impl GitHubReleasesFetcher {
220    /// Create a new fetcher with default options
221    pub fn new() -> Self {
222        // Try to get GitHub token from environment
223        let token = std::env::var("GITHUB_TOKEN")
224            .or_else(|_| std::env::var("GH_TOKEN"))
225            .ok();
226
227        let options = FetchOptions {
228            github_token: token,
229            ..FetchOptions::default()
230        };
231
232        Self { options }
233    }
234
235    /// Create a new fetcher with custom options
236    pub fn with_options(options: FetchOptions) -> Self {
237        Self { options }
238    }
239
240    /// Fetch version strings for a GitHub repository
241    ///
242    /// Tries GitHub API first, falls back to jsDelivr if GitHub is unavailable.
243    ///
244    /// # Arguments
245    /// * `owner` - Repository owner (e.g., "BurntSushi")
246    /// * `repo` - Repository name (e.g., "ripgrep")
247    ///
248    /// # Returns
249    /// * `VersionsResult` containing version strings and data source info
250    pub async fn fetch_versions_with_source(
251        &self,
252        owner: &str,
253        repo: &str,
254    ) -> Result<VersionsResult> {
255        // Try GitHub API first
256        match self.fetch_versions_from_github(owner, repo).await {
257            Ok(versions) => {
258                info!(
259                    "Fetched {} versions from GitHub API for {}/{}",
260                    versions.len(),
261                    owner,
262                    repo
263                );
264                Ok(VersionsResult {
265                    versions,
266                    source: DataSource::GitHub,
267                })
268            }
269            Err(github_err) => {
270                warn!(
271                    "GitHub API failed for {}/{}: {}, falling back to jsDelivr",
272                    owner, repo, github_err
273                );
274                // Fallback to jsDelivr
275                match self.fetch_versions_from_jsdelivr(owner, repo).await {
276                    Ok(versions) => {
277                        info!(
278                            "Fetched {} versions from jsDelivr for {}/{}",
279                            versions.len(),
280                            owner,
281                            repo
282                        );
283                        Ok(VersionsResult {
284                            versions,
285                            source: DataSource::JsDelivr,
286                        })
287                    }
288                    Err(jsdelivr_err) => {
289                        // Both sources failed
290                        Err(TurboCdnError::download(format!(
291                            "Failed to fetch versions for {}/{}: GitHub error: {}; jsDelivr error: {}",
292                            owner, repo, github_err, jsdelivr_err
293                        )))
294                    }
295                }
296            }
297        }
298    }
299
300    /// Fetch version strings (simple API without source info)
301    ///
302    /// # Arguments
303    /// * `owner` - Repository owner
304    /// * `repo` - Repository name
305    ///
306    /// # Returns
307    /// * `Vec<String>` of version tags
308    pub async fn fetch_versions(&self, owner: &str, repo: &str) -> Result<Vec<String>> {
309        let result = self.fetch_versions_with_source(owner, repo).await?;
310        Ok(result.versions)
311    }
312
313    /// List detailed release information for a GitHub repository
314    ///
315    /// Tries GitHub API first, falls back to jsDelivr if GitHub is unavailable.
316    /// Note: jsDelivr fallback returns limited information (only tag names).
317    ///
318    /// # Arguments
319    /// * `owner` - Repository owner
320    /// * `repo` - Repository name
321    ///
322    /// # Returns
323    /// * `ReleasesResult` containing release info and data source info
324    pub async fn list_releases_with_source(
325        &self,
326        owner: &str,
327        repo: &str,
328    ) -> Result<ReleasesResult> {
329        // Try GitHub API first
330        match self.list_releases_from_github(owner, repo).await {
331            Ok(releases) => {
332                info!(
333                    "Fetched {} releases from GitHub API for {}/{}",
334                    releases.len(),
335                    owner,
336                    repo
337                );
338                Ok(ReleasesResult {
339                    releases,
340                    source: DataSource::GitHub,
341                })
342            }
343            Err(github_err) => {
344                warn!(
345                    "GitHub API failed for {}/{}: {}, falling back to jsDelivr",
346                    owner, repo, github_err
347                );
348                // Fallback to jsDelivr (limited info)
349                match self.fetch_versions_from_jsdelivr(owner, repo).await {
350                    Ok(versions) => {
351                        let releases: Vec<ReleaseInfo> = versions
352                            .into_iter()
353                            .map(|tag| ReleaseInfo {
354                                tag_name: tag,
355                                name: None,
356                                prerelease: false,
357                                draft: false,
358                                published_at: None,
359                                assets: Vec::new(),
360                            })
361                            .collect();
362                        info!(
363                            "Fetched {} versions from jsDelivr for {}/{} (limited info)",
364                            releases.len(),
365                            owner,
366                            repo
367                        );
368                        Ok(ReleasesResult {
369                            releases,
370                            source: DataSource::JsDelivr,
371                        })
372                    }
373                    Err(jsdelivr_err) => Err(TurboCdnError::download(format!(
374                        "Failed to fetch releases for {}/{}: GitHub error: {}; jsDelivr error: {}",
375                        owner, repo, github_err, jsdelivr_err
376                    ))),
377                }
378            }
379        }
380    }
381
382    /// List detailed release information (simple API without source info)
383    pub async fn list_releases(&self, owner: &str, repo: &str) -> Result<Vec<ReleaseInfo>> {
384        let result = self.list_releases_with_source(owner, repo).await?;
385        Ok(result.releases)
386    }
387
388    /// Fetch the latest release version
389    pub async fn fetch_latest_version(&self, owner: &str, repo: &str) -> Result<String> {
390        let versions = self.fetch_versions(owner, repo).await?;
391        versions.into_iter().next().ok_or_else(|| {
392            TurboCdnError::download(format!("No releases found for {}/{}", owner, repo))
393        })
394    }
395
396    /// Fetch versions from GitHub API
397    async fn fetch_versions_from_github(&self, owner: &str, repo: &str) -> Result<Vec<String>> {
398        let releases = self.list_releases_from_github(owner, repo).await?;
399        Ok(releases.into_iter().map(|r| r.tag_name).collect())
400    }
401
402    /// Fetch detailed releases from GitHub API
403    async fn list_releases_from_github(&self, owner: &str, repo: &str) -> Result<Vec<ReleaseInfo>> {
404        crate::init_rustls_provider();
405
406        let url = format!(
407            "{}/repos/{}/{}/releases?per_page={}",
408            GITHUB_API_BASE, owner, repo, GITHUB_PER_PAGE
409        );
410
411        debug!("Fetching releases from GitHub: {}", url);
412
413        let mut builder = reqwest::Client::builder()
414            .timeout(self.options.timeout)
415            .build()
416            .map_err(|e| TurboCdnError::network(format!("Failed to create HTTP client: {e}")))?
417            .get(&url)
418            .header("User-Agent", "turbo-cdn")
419            .header("Accept", "application/vnd.github.v3+json");
420
421        // Add authentication if token is available
422        if let Some(ref token) = self.options.github_token {
423            builder = builder.header("Authorization", format!("Bearer {token}"));
424        }
425
426        let response = builder
427            .send()
428            .await
429            .map_err(|e| TurboCdnError::network(format!("GitHub API request failed: {e}")))?;
430
431        let status = response.status();
432
433        // Check for rate limiting
434        if status.as_u16() == 403 || status.as_u16() == 429 {
435            let rate_limit_remaining = response
436                .headers()
437                .get("x-ratelimit-remaining")
438                .and_then(|v| v.to_str().ok())
439                .unwrap_or("unknown");
440            return Err(TurboCdnError::rate_limit(format!(
441                "GitHub API rate limited (remaining: {rate_limit_remaining})"
442            )));
443        }
444
445        if !status.is_success() {
446            return Err(TurboCdnError::from_status_code(
447                status.as_u16(),
448                format!("{GITHUB_API_BASE}/repos/{owner}/{repo}/releases"),
449            ));
450        }
451
452        let github_releases: Vec<GitHubApiRelease> = response.json().await.map_err(|e| {
453            TurboCdnError::internal(format!("Failed to parse GitHub response: {e}"))
454        })?;
455
456        // Convert and filter
457        let releases: Vec<ReleaseInfo> = github_releases
458            .into_iter()
459            .filter(|r| {
460                (self.options.include_drafts || !r.draft)
461                    && (self.options.include_prereleases || !r.prerelease)
462            })
463            .map(|r| ReleaseInfo {
464                tag_name: r.tag_name,
465                name: r.name,
466                prerelease: r.prerelease,
467                draft: r.draft,
468                published_at: r.published_at,
469                assets: r
470                    .assets
471                    .into_iter()
472                    .map(|a| AssetInfo {
473                        name: a.name,
474                        size: a.size,
475                        browser_download_url: a.browser_download_url,
476                        content_type: a.content_type,
477                        download_count: a.download_count,
478                    })
479                    .collect(),
480            })
481            .collect();
482
483        // Apply max_versions limit
484        let releases = if let Some(max) = self.options.max_versions {
485            releases.into_iter().take(max).collect()
486        } else {
487            releases
488        };
489
490        Ok(releases)
491    }
492
493    /// Fetch versions from jsDelivr data API (fallback, no rate limits)
494    async fn fetch_versions_from_jsdelivr(&self, owner: &str, repo: &str) -> Result<Vec<String>> {
495        crate::init_rustls_provider();
496
497        let url = format!("{}/package/gh/{}/{}", JSDELIVR_DATA_API_BASE, owner, repo);
498
499        debug!("Fetching versions from jsDelivr: {}", url);
500
501        let response = reqwest::Client::builder()
502            .timeout(self.options.timeout)
503            .build()
504            .map_err(|e| TurboCdnError::network(format!("Failed to create HTTP client: {e}")))?
505            .get(&url)
506            .header("User-Agent", "turbo-cdn")
507            .send()
508            .await
509            .map_err(|e| TurboCdnError::network(format!("jsDelivr API request failed: {e}")))?;
510
511        let status = response.status();
512
513        if !status.is_success() {
514            return Err(TurboCdnError::from_status_code(status.as_u16(), url));
515        }
516
517        let package_info: JsDelivrPackageResponse = response.json().await.map_err(|e| {
518            TurboCdnError::internal(format!("Failed to parse jsDelivr response: {e}"))
519        })?;
520
521        let mut versions: Vec<String> = package_info
522            .versions
523            .into_iter()
524            .map(|v| v.version)
525            .collect();
526
527        // Filter pre-releases if needed (best-effort based on semver conventions)
528        if !self.options.include_prereleases {
529            versions.retain(|v| {
530                !v.contains("-alpha")
531                    && !v.contains("-beta")
532                    && !v.contains("-rc")
533                    && !v.contains("-dev")
534                    && !v.contains("-pre")
535            });
536        }
537
538        // Apply max_versions limit
539        if let Some(max) = self.options.max_versions {
540            versions.truncate(max);
541        }
542
543        Ok(versions)
544    }
545}
546
547impl Default for GitHubReleasesFetcher {
548    fn default() -> Self {
549        Self::new()
550    }
551}
552
553/// GitHub API release response (internal deserialization structure)
554#[derive(Debug, Deserialize)]
555struct GitHubApiRelease {
556    tag_name: String,
557    name: Option<String>,
558    prerelease: bool,
559    draft: bool,
560    published_at: Option<String>,
561    assets: Vec<GitHubApiAsset>,
562}
563
564/// GitHub API asset response (internal deserialization structure)
565#[derive(Debug, Deserialize)]
566struct GitHubApiAsset {
567    name: String,
568    size: u64,
569    browser_download_url: String,
570    content_type: Option<String>,
571    download_count: u64,
572}
573
574// ============================================================================
575// Convenience functions
576// ============================================================================
577
578/// Fetch version strings for a GitHub repository (convenience function)
579///
580/// Uses default options with automatic GitHub token detection from environment.
581///
582/// # Example
583/// ```rust,no_run
584/// use turbo_cdn::github_releases;
585///
586/// #[tokio::main]
587/// async fn main() -> turbo_cdn::Result<()> {
588///     let versions = github_releases::fetch_versions("BurntSushi", "ripgrep").await?;
589///     println!("Latest version: {}", versions[0]);
590///     Ok(())
591/// }
592/// ```
593pub async fn fetch_versions(owner: &str, repo: &str) -> Result<Vec<String>> {
594    GitHubReleasesFetcher::new()
595        .fetch_versions(owner, repo)
596        .await
597}
598
599/// List detailed release info for a GitHub repository (convenience function)
600pub async fn list_releases(owner: &str, repo: &str) -> Result<Vec<ReleaseInfo>> {
601    GitHubReleasesFetcher::new()
602        .list_releases(owner, repo)
603        .await
604}
605
606/// Fetch the latest release version (convenience function)
607pub async fn fetch_latest_version(owner: &str, repo: &str) -> Result<String> {
608    GitHubReleasesFetcher::new()
609        .fetch_latest_version(owner, repo)
610        .await
611}
612
613/// Fetch versions with detailed source information (convenience function)
614pub async fn fetch_versions_with_source(owner: &str, repo: &str) -> Result<VersionsResult> {
615    GitHubReleasesFetcher::new()
616        .fetch_versions_with_source(owner, repo)
617        .await
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn test_fetch_options_default() {
626        let options = FetchOptions::default();
627        assert!(!options.include_prereleases);
628        assert!(!options.include_drafts);
629        assert!(options.max_versions.is_none());
630        assert_eq!(options.timeout, API_TIMEOUT);
631    }
632
633    #[test]
634    fn test_fetch_options_builder() {
635        let options = FetchOptions::new()
636            .with_prereleases(true)
637            .with_drafts(true)
638            .with_max_versions(10)
639            .with_github_token("test-token")
640            .with_timeout(Duration::from_secs(60));
641
642        assert!(options.include_prereleases);
643        assert!(options.include_drafts);
644        assert_eq!(options.max_versions, Some(10));
645        assert_eq!(options.github_token, Some("test-token".to_string()));
646        assert_eq!(options.timeout, Duration::from_secs(60));
647    }
648
649    #[test]
650    fn test_data_source_display() {
651        assert_eq!(DataSource::GitHub.to_string(), "GitHub API");
652        assert_eq!(DataSource::JsDelivr.to_string(), "jsDelivr CDN");
653    }
654
655    #[test]
656    fn test_release_info_serialization() {
657        let release = ReleaseInfo {
658            tag_name: "v1.0.0".to_string(),
659            name: Some("Release 1.0.0".to_string()),
660            prerelease: false,
661            draft: false,
662            published_at: Some("2024-01-01T00:00:00Z".to_string()),
663            assets: vec![AssetInfo {
664                name: "app-linux-x64.tar.gz".to_string(),
665                size: 1024,
666                browser_download_url:
667                    "https://github.com/owner/repo/releases/download/v1.0.0/app-linux-x64.tar.gz"
668                        .to_string(),
669                content_type: Some("application/gzip".to_string()),
670                download_count: 100,
671            }],
672        };
673
674        let json = serde_json::to_string(&release).unwrap();
675        assert!(json.contains("v1.0.0"));
676        assert!(json.contains("app-linux-x64.tar.gz"));
677    }
678
679    #[test]
680    fn test_fetcher_default() {
681        let fetcher = GitHubReleasesFetcher::new();
682        assert!(!fetcher.options.include_prereleases);
683    }
684
685    #[test]
686    fn test_fetcher_with_options() {
687        let options = FetchOptions::new()
688            .with_prereleases(true)
689            .with_max_versions(5);
690        let fetcher = GitHubReleasesFetcher::with_options(options);
691        assert!(fetcher.options.include_prereleases);
692        assert_eq!(fetcher.options.max_versions, Some(5));
693    }
694}