1use crate::error::{Result, TurboCdnError};
42use serde::{Deserialize, Serialize};
43use std::time::Duration;
44use tracing::{debug, info, warn};
45
46const API_TIMEOUT: Duration = Duration::from_secs(30);
48
49const GITHUB_API_BASE: &str = "https://api.github.com";
51
52const JSDELIVR_DATA_API_BASE: &str = "https://data.jsdelivr.com/v1";
54
55const GITHUB_PER_PAGE: u32 = 100;
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ReleaseInfo {
61 pub tag_name: String,
63 pub name: Option<String>,
65 pub prerelease: bool,
67 pub draft: bool,
69 pub published_at: Option<String>,
71 pub assets: Vec<AssetInfo>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct AssetInfo {
78 pub name: String,
80 pub size: u64,
82 pub browser_download_url: String,
84 pub content_type: Option<String>,
86 pub download_count: u64,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92struct JsDelivrPackageResponse {
93 #[serde(rename = "type")]
95 package_type: Option<String>,
96 name: Option<String>,
98 versions: Vec<JsDelivrVersion>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104struct JsDelivrVersion {
105 version: String,
107}
108
109#[derive(Debug, Clone)]
111pub struct FetchOptions {
112 pub include_prereleases: bool,
114 pub include_drafts: bool,
116 pub max_versions: Option<usize>,
118 pub github_token: Option<String>,
120 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 pub fn new() -> Self {
139 Self::default()
140 }
141
142 pub fn with_prereleases(mut self, include: bool) -> Self {
144 self.include_prereleases = include;
145 self
146 }
147
148 pub fn with_drafts(mut self, include: bool) -> Self {
150 self.include_drafts = include;
151 self
152 }
153
154 pub fn with_max_versions(mut self, max: usize) -> Self {
156 self.max_versions = Some(max);
157 self
158 }
159
160 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 pub fn with_timeout(mut self, timeout: Duration) -> Self {
168 self.timeout = timeout;
169 self
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum DataSource {
176 GitHub,
178 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#[derive(Debug, Clone)]
193pub struct VersionsResult {
194 pub versions: Vec<String>,
196 pub source: DataSource,
198}
199
200#[derive(Debug, Clone)]
202pub struct ReleasesResult {
203 pub releases: Vec<ReleaseInfo>,
205 pub source: DataSource,
207}
208
209#[derive(Debug, Clone)]
215pub struct GitHubReleasesFetcher {
216 options: FetchOptions,
217}
218
219impl GitHubReleasesFetcher {
220 pub fn new() -> Self {
222 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 pub fn with_options(options: FetchOptions) -> Self {
237 Self { options }
238 }
239
240 pub async fn fetch_versions_with_source(
251 &self,
252 owner: &str,
253 repo: &str,
254 ) -> Result<VersionsResult> {
255 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 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 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 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 pub async fn list_releases_with_source(
325 &self,
326 owner: &str,
327 repo: &str,
328 ) -> Result<ReleasesResult> {
329 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 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 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 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 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 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 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 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 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 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 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 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 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#[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#[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
574pub async fn fetch_versions(owner: &str, repo: &str) -> Result<Vec<String>> {
594 GitHubReleasesFetcher::new()
595 .fetch_versions(owner, repo)
596 .await
597}
598
599pub async fn list_releases(owner: &str, repo: &str) -> Result<Vec<ReleaseInfo>> {
601 GitHubReleasesFetcher::new()
602 .list_releases(owner, repo)
603 .await
604}
605
606pub async fn fetch_latest_version(owner: &str, repo: &str) -> Result<String> {
608 GitHubReleasesFetcher::new()
609 .fetch_latest_version(owner, repo)
610 .await
611}
612
613pub 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}