Skip to main content

cuenv_tools_github/
lib.rs

1//! GitHub Releases tool provider for cuenv.
2//!
3//! Fetches development tools from GitHub Releases. Supports:
4//! - Template variables in asset names: `{version}`, `{os}`, `{arch}`
5//! - Automatic archive extraction (zip, tar.gz)
6//! - Path-based binary extraction from archives
7
8use async_trait::async_trait;
9use cuenv_core::Result;
10use cuenv_core::tools::{
11    Arch, FetchedTool, Os, Platform, ResolvedTool, ToolOptions, ToolProvider, ToolResolveRequest,
12    ToolSource,
13};
14use flate2::read::GzDecoder;
15use reqwest::Client;
16use serde::Deserialize;
17use sha2::{Digest, Sha256};
18use std::io::{Cursor, Read};
19use std::path::PathBuf;
20use tar::Archive;
21use tokio::io::AsyncReadExt;
22use tracing::{debug, info};
23
24/// Rate limit information from GitHub API response headers.
25#[derive(Debug, Default)]
26struct RateLimitInfo {
27    /// Maximum requests allowed per hour.
28    limit: Option<u32>,
29    /// Remaining requests in the current window.
30    remaining: Option<u32>,
31    /// Unix timestamp when the rate limit resets.
32    reset: Option<u64>,
33}
34
35impl RateLimitInfo {
36    /// Extract rate limit info from response headers.
37    fn from_headers(headers: &reqwest::header::HeaderMap) -> Self {
38        Self {
39            limit: headers
40                .get("x-ratelimit-limit")
41                .and_then(|v| v.to_str().ok())
42                .and_then(|s| s.parse().ok()),
43            remaining: headers
44                .get("x-ratelimit-remaining")
45                .and_then(|v| v.to_str().ok())
46                .and_then(|s| s.parse().ok()),
47            reset: headers
48                .get("x-ratelimit-reset")
49                .and_then(|v| v.to_str().ok())
50                .and_then(|s| s.parse().ok()),
51        }
52    }
53
54    /// Check if the rate limit has been exceeded.
55    fn is_exceeded(&self) -> bool {
56        self.remaining == Some(0)
57    }
58
59    /// Format the reset time as a human-readable duration.
60    fn format_reset_duration(&self) -> Option<String> {
61        let reset_ts = self.reset?;
62        let now = std::time::SystemTime::now()
63            .duration_since(std::time::UNIX_EPOCH)
64            .ok()?
65            .as_secs();
66
67        if reset_ts <= now {
68            return Some("now".to_string());
69        }
70
71        let seconds_remaining = reset_ts - now;
72        let minutes = seconds_remaining / 60;
73        let hours = minutes / 60;
74
75        if hours > 0 {
76            Some(format!("{} hour(s) {} minute(s)", hours, minutes % 60))
77        } else if minutes > 0 {
78            Some(format!("{} minute(s)", minutes))
79        } else {
80            Some(format!("{} second(s)", seconds_remaining))
81        }
82    }
83
84    /// Format rate limit status (e.g., "0/60 requests remaining").
85    fn format_status(&self) -> Option<String> {
86        match (self.remaining, self.limit) {
87            (Some(remaining), Some(limit)) => {
88                Some(format!("{}/{} requests remaining", remaining, limit))
89            }
90            _ => None,
91        }
92    }
93}
94
95/// GitHub release metadata from the API.
96#[derive(Debug, Deserialize)]
97struct Release {
98    #[allow(dead_code)] // Deserialized from GitHub API response
99    tag_name: String,
100    assets: Vec<Asset>,
101}
102
103/// GitHub release asset.
104#[derive(Debug, Deserialize)]
105struct Asset {
106    name: String,
107    browser_download_url: String,
108}
109
110/// Tool provider for GitHub Releases.
111///
112/// Fetches binaries from GitHub Releases, supporting template expansion
113/// for platform-specific asset names.
114pub struct GitHubToolProvider {
115    client: Client,
116}
117
118impl Default for GitHubToolProvider {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl GitHubToolProvider {
125    /// Create a new GitHub tool provider.
126    ///
127    /// # Panics
128    ///
129    /// This function uses `expect` internally because `reqwest::Client::builder().build()`
130    /// only fails with invalid TLS configuration, which cannot happen with default settings.
131    /// The panic is acceptable here as it indicates a fundamental environment issue.
132    #[must_use]
133    pub fn new() -> Self {
134        // SAFETY: Client::builder().build() only fails if:
135        // 1. TLS backend fails to initialize (system-level issue)
136        // 2. Invalid proxy configuration (we don't set any)
137        // With default settings and user_agent only, this cannot fail.
138        Self {
139            client: Client::builder()
140                .user_agent("cuenv")
141                .build()
142                .expect("Failed to create HTTP client - TLS backend initialization failed"),
143        }
144    }
145
146    /// Get the cache directory for a tool.
147    fn tool_cache_dir(&self, options: &ToolOptions, name: &str, version: &str) -> PathBuf {
148        options.cache_dir().join("github").join(name).join(version)
149    }
150
151    /// Expand template variables in a string.
152    fn expand_template(&self, template: &str, version: &str, platform: &Platform) -> String {
153        let os_str = match platform.os {
154            Os::Darwin => "darwin",
155            Os::Linux => "linux",
156        };
157        let arch_str = match platform.arch {
158            Arch::Arm64 => "aarch64",
159            Arch::X86_64 => "x86_64",
160        };
161
162        template
163            .replace("{version}", version)
164            .replace("{os}", os_str)
165            .replace("{arch}", arch_str)
166    }
167
168    /// Get the effective token: GITHUB_TOKEN > GH_TOKEN > runtime token
169    fn get_effective_token(runtime_token: Option<&str>) -> Option<String> {
170        std::env::var("GITHUB_TOKEN")
171            .ok()
172            .or_else(|| std::env::var("GH_TOKEN").ok())
173            .or_else(|| runtime_token.map(String::from))
174    }
175
176    /// Fetch release information from GitHub API.
177    async fn fetch_release(&self, repo: &str, tag: &str, token: Option<&str>) -> Result<Release> {
178        let url = format!(
179            "https://api.github.com/repos/{}/releases/tags/{}",
180            repo, tag
181        );
182        debug!(%url, "Fetching GitHub release");
183
184        let effective_token = Self::get_effective_token(token);
185        let is_authenticated = effective_token.is_some();
186
187        let mut request = self.client.get(&url);
188        if let Some(token) = effective_token {
189            request = request.header("Authorization", format!("Bearer {}", token));
190        }
191
192        let response = request.send().await.map_err(|e| {
193            cuenv_core::Error::tool_resolution(format!("Failed to fetch release: {}", e))
194        })?;
195
196        let status = response.status();
197        if !status.is_success() {
198            let rate_limit = RateLimitInfo::from_headers(response.headers());
199
200            return Err(Self::build_api_error(
201                status,
202                &rate_limit,
203                is_authenticated,
204                &format!("release {} {}", repo, tag),
205            ));
206        }
207
208        response.json().await.map_err(|e| {
209            cuenv_core::Error::tool_resolution(format!("Failed to parse release: {}", e))
210        })
211    }
212
213    /// Build an appropriate error for GitHub API failures.
214    fn build_api_error(
215        status: reqwest::StatusCode,
216        rate_limit: &RateLimitInfo,
217        is_authenticated: bool,
218        resource: &str,
219    ) -> cuenv_core::Error {
220        // Handle rate limit exceeded (403 with remaining=0)
221        if status == reqwest::StatusCode::FORBIDDEN && rate_limit.is_exceeded() {
222            let mut message = "GitHub API rate limit exceeded".to_string();
223
224            if let Some(status_str) = rate_limit.format_status() {
225                message.push_str(&format!(" ({})", status_str));
226            }
227            if let Some(reset_str) = rate_limit.format_reset_duration() {
228                message.push_str(&format!(". Resets in {}", reset_str));
229            }
230
231            let help = if is_authenticated {
232                "Wait for the rate limit to reset, or use a different GitHub token"
233            } else {
234                "Set GITHUB_TOKEN environment variable with `public_repo` scope \
235                 for 5000 requests/hour (unauthenticated: 60/hour)"
236            };
237
238            return cuenv_core::Error::tool_resolution_with_help(message, help);
239        }
240
241        // Handle 403 Forbidden (not rate limited)
242        if status == reqwest::StatusCode::FORBIDDEN {
243            let message = format!("Access denied to {} (HTTP 403 Forbidden)", resource);
244            let help = if is_authenticated {
245                "Check that your GITHUB_TOKEN has the required permissions. \
246                 For private repositories, ensure the token has the `repo` scope"
247            } else {
248                "Set GITHUB_TOKEN environment variable with `public_repo` scope to access this resource"
249            };
250            return cuenv_core::Error::tool_resolution_with_help(message, help);
251        }
252
253        // Handle 404 Not Found
254        if status == reqwest::StatusCode::NOT_FOUND {
255            return cuenv_core::Error::tool_resolution(format!(
256                "{} not found (HTTP 404)",
257                resource
258            ));
259        }
260
261        // Handle 401 Unauthorized
262        if status == reqwest::StatusCode::UNAUTHORIZED {
263            let help = "Your GITHUB_TOKEN may be invalid or expired. \
264                       Generate a new token at https://github.com/settings/tokens";
265            return cuenv_core::Error::tool_resolution_with_help(
266                format!("Authentication failed for {} (HTTP 401)", resource),
267                help,
268            );
269        }
270
271        // Generic error for other status codes
272        cuenv_core::Error::tool_resolution(format!("Failed to fetch {}: HTTP {}", resource, status))
273    }
274
275    /// Download an asset from GitHub.
276    async fn download_asset(&self, url: &str, token: Option<&str>) -> Result<Vec<u8>> {
277        debug!(%url, "Downloading GitHub asset");
278
279        let effective_token = Self::get_effective_token(token);
280        let is_authenticated = effective_token.is_some();
281
282        let mut request = self.client.get(url);
283        if let Some(token) = effective_token {
284            request = request.header("Authorization", format!("Bearer {}", token));
285        }
286
287        let response = request.send().await.map_err(|e| {
288            cuenv_core::Error::tool_resolution(format!("Failed to download asset: {}", e))
289        })?;
290
291        let status = response.status();
292        if !status.is_success() {
293            let rate_limit = RateLimitInfo::from_headers(response.headers());
294            return Err(Self::build_api_error(
295                status,
296                &rate_limit,
297                is_authenticated,
298                "asset download",
299            ));
300        }
301
302        response
303            .bytes()
304            .await
305            .map(|b| b.to_vec())
306            .map_err(|e| cuenv_core::Error::tool_resolution(format!("Failed to read asset: {}", e)))
307    }
308
309    /// Extract a binary from an archive.
310    fn extract_binary(
311        &self,
312        data: &[u8],
313        asset_name: &str,
314        binary_path: Option<&str>,
315        dest: &std::path::Path,
316    ) -> Result<PathBuf> {
317        // Determine archive type
318        let is_zip = asset_name.ends_with(".zip");
319        let is_tar_gz = asset_name.ends_with(".tar.gz") || asset_name.ends_with(".tgz");
320
321        if is_zip {
322            self.extract_from_zip(data, binary_path, dest)
323        } else if is_tar_gz {
324            self.extract_from_tar_gz(data, binary_path, dest)
325        } else {
326            // Assume it's a raw binary
327            std::fs::create_dir_all(dest)?;
328            let binary_name = std::path::Path::new(asset_name)
329                .file_stem()
330                .and_then(|s| s.to_str())
331                .unwrap_or(asset_name);
332            let binary_dest = dest.join(binary_name);
333            std::fs::write(&binary_dest, data)?;
334
335            #[cfg(unix)]
336            {
337                use std::os::unix::fs::PermissionsExt;
338                let mut perms = std::fs::metadata(&binary_dest)?.permissions();
339                perms.set_mode(0o755);
340                std::fs::set_permissions(&binary_dest, perms)?;
341            }
342
343            Ok(binary_dest)
344        }
345    }
346
347    /// Extract from a zip archive.
348    ///
349    /// Uses a temporary directory for atomic extraction - if extraction fails
350    /// partway through, no partial files are left in the destination.
351    fn extract_from_zip(
352        &self,
353        data: &[u8],
354        binary_path: Option<&str>,
355        dest: &std::path::Path,
356    ) -> Result<PathBuf> {
357        let cursor = Cursor::new(data);
358        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| {
359            cuenv_core::Error::tool_resolution(format!("Failed to open zip: {}", e))
360        })?;
361
362        // If a specific path is requested, extract just that file (no temp dir needed)
363        if let Some(path) = binary_path {
364            for i in 0..archive.len() {
365                let mut file = archive.by_index(i).map_err(|e| {
366                    cuenv_core::Error::tool_resolution(format!("Failed to read zip entry: {}", e))
367                })?;
368
369                let name = file.name().to_string();
370                if name.ends_with(path) || name == path {
371                    std::fs::create_dir_all(dest)?;
372                    let file_name = std::path::Path::new(&name)
373                        .file_name()
374                        .and_then(|s| s.to_str())
375                        .unwrap_or(path);
376                    let dest_path = dest.join(file_name);
377
378                    let mut content = Vec::new();
379                    file.read_to_end(&mut content)?;
380                    std::fs::write(&dest_path, &content)?;
381
382                    #[cfg(unix)]
383                    {
384                        use std::os::unix::fs::PermissionsExt;
385                        let mut perms = std::fs::metadata(&dest_path)?.permissions();
386                        perms.set_mode(0o755);
387                        std::fs::set_permissions(&dest_path, perms)?;
388                    }
389
390                    return Ok(dest_path);
391                }
392            }
393
394            return Err(cuenv_core::Error::tool_resolution(format!(
395                "Binary '{}' not found in archive",
396                path
397            )));
398        }
399
400        // Extract all files to a temp directory first for atomic operation
401        let temp_dir = dest.with_file_name(format!(
402            ".{}.tmp",
403            dest.file_name()
404                .and_then(|s| s.to_str())
405                .unwrap_or("extract")
406        ));
407
408        // Clean up any previous failed extraction
409        if temp_dir.exists() {
410            std::fs::remove_dir_all(&temp_dir)?;
411        }
412        std::fs::create_dir_all(&temp_dir)?;
413
414        // Extract to temp directory
415        let extract_result = (|| -> Result<()> {
416            for i in 0..archive.len() {
417                let mut file = archive.by_index(i).map_err(|e| {
418                    cuenv_core::Error::tool_resolution(format!("Failed to read zip entry: {}", e))
419                })?;
420
421                let outpath = match file.enclosed_name() {
422                    Some(path) => temp_dir.join(path),
423                    None => continue,
424                };
425
426                if file.is_dir() {
427                    std::fs::create_dir_all(&outpath)?;
428                } else {
429                    if let Some(p) = outpath.parent() {
430                        std::fs::create_dir_all(p)?;
431                    }
432                    let mut content = Vec::new();
433                    file.read_to_end(&mut content)?;
434                    std::fs::write(&outpath, &content)?;
435
436                    #[cfg(unix)]
437                    if let Some(mode) = file.unix_mode() {
438                        use std::os::unix::fs::PermissionsExt;
439                        let mut perms = std::fs::metadata(&outpath)?.permissions();
440                        perms.set_mode(mode);
441                        std::fs::set_permissions(&outpath, perms)?;
442                    }
443                }
444            }
445            Ok(())
446        })();
447
448        // On failure, clean up temp directory
449        if let Err(e) = extract_result {
450            let _ = std::fs::remove_dir_all(&temp_dir);
451            return Err(e);
452        }
453
454        // Atomic move: remove destination if exists, then rename temp to dest
455        if dest.exists() {
456            std::fs::remove_dir_all(dest)?;
457        }
458        std::fs::rename(&temp_dir, dest)?;
459
460        // Find the main binary (first executable in bin/ or root)
461        self.find_main_binary(dest)
462    }
463
464    /// Extract from a tar.gz archive.
465    fn extract_from_tar_gz(
466        &self,
467        data: &[u8],
468        binary_path: Option<&str>,
469        dest: &std::path::Path,
470    ) -> Result<PathBuf> {
471        let cursor = Cursor::new(data);
472        let decoder = GzDecoder::new(cursor);
473        let mut archive = Archive::new(decoder);
474
475        std::fs::create_dir_all(dest)?;
476
477        if let Some(path) = binary_path {
478            // Look for specific file
479            for entry in archive.entries().map_err(|e| {
480                cuenv_core::Error::tool_resolution(format!("Failed to read tar: {}", e))
481            })? {
482                let mut entry = entry.map_err(|e| {
483                    cuenv_core::Error::tool_resolution(format!("Failed to read tar entry: {}", e))
484                })?;
485
486                let entry_path = entry.path().map_err(|e| {
487                    cuenv_core::Error::tool_resolution(format!("Invalid path in tar: {}", e))
488                })?;
489
490                let path_str = entry_path.to_string_lossy();
491                if path_str.ends_with(path) || path_str.as_ref() == path {
492                    let file_name = std::path::Path::new(path)
493                        .file_name()
494                        .and_then(|s| s.to_str())
495                        .unwrap_or(path);
496                    let dest_path = dest.join(file_name);
497
498                    let mut content = Vec::new();
499                    entry.read_to_end(&mut content)?;
500                    std::fs::write(&dest_path, &content)?;
501
502                    #[cfg(unix)]
503                    {
504                        use std::os::unix::fs::PermissionsExt;
505                        let mut perms = std::fs::metadata(&dest_path)?.permissions();
506                        perms.set_mode(0o755);
507                        std::fs::set_permissions(&dest_path, perms)?;
508                    }
509
510                    return Ok(dest_path);
511                }
512            }
513
514            return Err(cuenv_core::Error::tool_resolution(format!(
515                "Binary '{}' not found in archive",
516                path
517            )));
518        }
519
520        // Extract all files
521        archive.unpack(dest).map_err(|e| {
522            cuenv_core::Error::tool_resolution(format!("Failed to extract tar: {}", e))
523        })?;
524
525        // Find the main binary
526        self.find_main_binary(dest)
527    }
528
529    /// Find the main binary in an extracted directory.
530    fn find_main_binary(&self, dir: &std::path::Path) -> Result<PathBuf> {
531        // First, look for binaries in bin/
532        let bin_dir = dir.join("bin");
533        if bin_dir.exists() {
534            for entry in std::fs::read_dir(&bin_dir)? {
535                let entry = entry?;
536                let path = entry.path();
537                if path.is_file() {
538                    return Ok(path);
539                }
540            }
541        }
542
543        // Then look for executables in root
544        for entry in std::fs::read_dir(dir)? {
545            let entry = entry?;
546            let path = entry.path();
547
548            if path.is_file() {
549                #[cfg(unix)]
550                {
551                    use std::os::unix::fs::PermissionsExt;
552                    if let Ok(meta) = std::fs::metadata(&path) {
553                        if meta.permissions().mode() & 0o111 != 0 {
554                            return Ok(path);
555                        }
556                    }
557                }
558                #[cfg(not(unix))]
559                {
560                    // On non-Unix, just return the first file
561                    return Ok(path);
562                }
563            }
564        }
565
566        Err(cuenv_core::Error::tool_resolution(
567            "No binary found in extracted archive".to_string(),
568        ))
569    }
570}
571
572#[async_trait]
573impl ToolProvider for GitHubToolProvider {
574    fn name(&self) -> &'static str {
575        "github"
576    }
577
578    fn description(&self) -> &'static str {
579        "Fetch tools from GitHub Releases"
580    }
581
582    fn can_handle(&self, source: &ToolSource) -> bool {
583        matches!(source, ToolSource::GitHub { .. })
584    }
585
586    async fn resolve(&self, request: &ToolResolveRequest<'_>) -> Result<ResolvedTool> {
587        let tool_name = request.tool_name;
588        let version = request.version;
589        let platform = request.platform;
590        let config = request.config;
591        let token = request.token;
592
593        let repo = config
594            .get("repo")
595            .and_then(|v| v.as_str())
596            .ok_or_else(|| cuenv_core::Error::tool_resolution("Missing 'repo' in config"))?;
597
598        let asset_template = config
599            .get("asset")
600            .and_then(|v| v.as_str())
601            .ok_or_else(|| cuenv_core::Error::tool_resolution("Missing 'asset' in config"))?;
602
603        let tag_template = config
604            .get("tag")
605            .and_then(|v| v.as_str())
606            .map(String::from)
607            .unwrap_or_else(|| {
608                let prefix = config
609                    .get("tagPrefix")
610                    .and_then(|v| v.as_str())
611                    .unwrap_or("");
612                format!("{prefix}{{version}}")
613            });
614
615        let path = config.get("path").and_then(|v| v.as_str());
616
617        info!(%tool_name, %repo, %version, %platform, "Resolving GitHub release");
618
619        // Expand templates
620        let tag = self.expand_template(&tag_template, version, platform);
621        let asset = self.expand_template(asset_template, version, platform);
622        let expanded_path = path.map(|p| self.expand_template(p, version, platform));
623
624        // Fetch release to verify it exists (uses runtime token if provided)
625        let release = self.fetch_release(repo, &tag, token).await?;
626
627        // Find the asset
628        let found_asset = release.assets.iter().find(|a| a.name == asset);
629        if found_asset.is_none() {
630            let available: Vec<_> = release.assets.iter().map(|a| &a.name).collect();
631            return Err(cuenv_core::Error::tool_resolution(format!(
632                "Asset '{}' not found in release. Available: {:?}",
633                asset, available
634            )));
635        }
636
637        debug!(%tag, %asset, "Resolved GitHub release");
638
639        Ok(ResolvedTool {
640            name: tool_name.to_string(),
641            version: version.to_string(),
642            platform: platform.clone(),
643            source: ToolSource::GitHub {
644                repo: repo.to_string(),
645                tag,
646                asset,
647                path: expanded_path,
648            },
649        })
650    }
651
652    async fn fetch(&self, resolved: &ResolvedTool, options: &ToolOptions) -> Result<FetchedTool> {
653        let ToolSource::GitHub {
654            repo,
655            tag,
656            asset,
657            path,
658        } = &resolved.source
659        else {
660            return Err(cuenv_core::Error::tool_resolution(
661                "GitHubToolProvider received non-GitHub source".to_string(),
662            ));
663        };
664
665        info!(
666            tool = %resolved.name,
667            %repo,
668            %tag,
669            %asset,
670            "Fetching GitHub release"
671        );
672
673        // Check cache - binaries go in bin/ subdirectory for consistency with other providers
674        let cache_dir = self.tool_cache_dir(options, &resolved.name, &resolved.version);
675        let bin_dir = cache_dir.join("bin");
676        let binary_path = bin_dir.join(&resolved.name);
677
678        if binary_path.exists() && !options.force_refetch {
679            debug!(?binary_path, "Tool already cached");
680            let sha256 = compute_file_sha256(&binary_path).await?;
681            return Ok(FetchedTool {
682                name: resolved.name.clone(),
683                binary_path,
684                sha256,
685            });
686        }
687
688        // Fetch release and download asset (no runtime token for fetch - uses env vars)
689        let release = self.fetch_release(repo, tag, None).await?;
690        let found_asset = release
691            .assets
692            .iter()
693            .find(|a| &a.name == asset)
694            .ok_or_else(|| {
695                cuenv_core::Error::tool_resolution(format!("Asset '{}' not found", asset))
696            })?;
697
698        let data = self
699            .download_asset(&found_asset.browser_download_url, None)
700            .await?;
701
702        // Ensure bin directory exists
703        std::fs::create_dir_all(&bin_dir)?;
704
705        // Extract binary to a temp location first, then move to bin/
706        let extracted = self.extract_binary(&data, asset, path.as_deref(), &cache_dir)?;
707
708        // Move to bin/<tool_name> for consistency with other providers
709        let final_path = bin_dir.join(&resolved.name);
710        if extracted != final_path {
711            // If extracted to a different location, move it
712            if final_path.exists() {
713                std::fs::remove_file(&final_path)?;
714            }
715            std::fs::rename(&extracted, &final_path)?;
716        }
717
718        let sha256 = compute_file_sha256(&final_path).await?;
719
720        info!(
721            tool = %resolved.name,
722            binary = ?final_path,
723            %sha256,
724            "Fetched GitHub release"
725        );
726
727        Ok(FetchedTool {
728            name: resolved.name.clone(),
729            binary_path: final_path,
730            sha256,
731        })
732    }
733
734    fn is_cached(&self, resolved: &ResolvedTool, options: &ToolOptions) -> bool {
735        let cache_dir = self.tool_cache_dir(options, &resolved.name, &resolved.version);
736        let binary_path = cache_dir.join("bin").join(&resolved.name);
737        binary_path.exists()
738    }
739}
740
741/// Compute SHA256 hash of a file.
742async fn compute_file_sha256(path: &std::path::Path) -> Result<String> {
743    let mut file = tokio::fs::File::open(path).await?;
744    let mut hasher = Sha256::new();
745    let mut buffer = vec![0u8; 8192];
746
747    loop {
748        let n = file.read(&mut buffer).await?;
749        if n == 0 {
750            break;
751        }
752        hasher.update(&buffer[..n]);
753    }
754
755    Ok(format!("{:x}", hasher.finalize()))
756}
757
758#[cfg(test)]
759#[allow(unsafe_code)]
760mod tests {
761    use super::*;
762    use tempfile::TempDir;
763
764    // ==========================================================================
765    // GitHubToolProvider construction and ToolProvider trait tests
766    // ==========================================================================
767
768    #[test]
769    fn test_provider_name() {
770        let provider = GitHubToolProvider::new();
771        assert_eq!(provider.name(), "github");
772    }
773
774    #[test]
775    fn test_provider_description() {
776        let provider = GitHubToolProvider::new();
777        assert_eq!(provider.description(), "Fetch tools from GitHub Releases");
778    }
779
780    #[test]
781    fn test_provider_default() {
782        let provider = GitHubToolProvider::default();
783        assert_eq!(provider.name(), "github");
784    }
785
786    #[test]
787    fn test_can_handle() {
788        let provider = GitHubToolProvider::new();
789
790        let github_source = ToolSource::GitHub {
791            repo: "org/repo".into(),
792            tag: "v1".into(),
793            asset: "file.zip".into(),
794            path: None,
795        };
796        assert!(provider.can_handle(&github_source));
797
798        let nix_source = ToolSource::Nix {
799            flake: "nixpkgs".into(),
800            package: "jq".into(),
801            output: None,
802        };
803        assert!(!provider.can_handle(&nix_source));
804    }
805
806    #[test]
807    fn test_can_handle_github_with_path() {
808        let provider = GitHubToolProvider::new();
809
810        let source = ToolSource::GitHub {
811            repo: "owner/repo".into(),
812            tag: "v1.0.0".into(),
813            asset: "archive.tar.gz".into(),
814            path: Some("bin/tool".into()),
815        };
816        assert!(provider.can_handle(&source));
817    }
818
819    // ==========================================================================
820    // expand_template tests
821    // ==========================================================================
822
823    #[test]
824    fn test_expand_template() {
825        let provider = GitHubToolProvider::new();
826        let platform = Platform::new(Os::Darwin, Arch::Arm64);
827
828        assert_eq!(
829            provider.expand_template("bun-{os}-{arch}.zip", "1.0.0", &platform),
830            "bun-darwin-aarch64.zip"
831        );
832
833        assert_eq!(
834            provider.expand_template("v{version}", "1.0.0", &platform),
835            "v1.0.0"
836        );
837    }
838
839    #[test]
840    fn test_expand_template_linux_x86_64() {
841        let provider = GitHubToolProvider::new();
842        let platform = Platform::new(Os::Linux, Arch::X86_64);
843
844        assert_eq!(
845            provider.expand_template("{os}-{arch}", "1.0.0", &platform),
846            "linux-x86_64"
847        );
848    }
849
850    #[test]
851    fn test_expand_template_all_placeholders() {
852        let provider = GitHubToolProvider::new();
853        let platform = Platform::new(Os::Darwin, Arch::X86_64);
854
855        assert_eq!(
856            provider.expand_template("tool-{version}-{os}-{arch}.zip", "2.5.1", &platform),
857            "tool-2.5.1-darwin-x86_64.zip"
858        );
859    }
860
861    #[test]
862    fn test_expand_template_no_placeholders() {
863        let provider = GitHubToolProvider::new();
864        let platform = Platform::new(Os::Linux, Arch::Arm64);
865
866        assert_eq!(
867            provider.expand_template("static-name.tar.gz", "1.0.0", &platform),
868            "static-name.tar.gz"
869        );
870    }
871
872    // ==========================================================================
873    // tool_cache_dir tests
874    // ==========================================================================
875
876    #[test]
877    fn test_tool_cache_dir() {
878        let provider = GitHubToolProvider::new();
879        let temp_dir = TempDir::new().unwrap();
880        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
881
882        let cache_dir = provider.tool_cache_dir(&options, "mytool", "1.2.3");
883
884        assert!(cache_dir.ends_with("github/mytool/1.2.3"));
885        assert!(cache_dir.starts_with(temp_dir.path()));
886    }
887
888    #[test]
889    fn test_tool_cache_dir_different_versions() {
890        let provider = GitHubToolProvider::new();
891        let temp_dir = TempDir::new().unwrap();
892        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
893
894        let cache_v1 = provider.tool_cache_dir(&options, "tool", "1.0.0");
895        let cache_v2 = provider.tool_cache_dir(&options, "tool", "2.0.0");
896
897        assert_ne!(cache_v1, cache_v2);
898        assert!(cache_v1.ends_with("1.0.0"));
899        assert!(cache_v2.ends_with("2.0.0"));
900    }
901
902    // ==========================================================================
903    // get_effective_token tests
904    // ==========================================================================
905
906    #[test]
907    fn test_get_effective_token_runtime_only() {
908        // Clear env vars first
909        // SAFETY: Test runs in isolation
910        unsafe {
911            std::env::remove_var("GITHUB_TOKEN");
912            std::env::remove_var("GH_TOKEN");
913        }
914
915        let token = GitHubToolProvider::get_effective_token(Some("runtime-token"));
916        assert_eq!(token, Some("runtime-token".to_string()));
917    }
918
919    #[test]
920    fn test_get_effective_token_none() {
921        // SAFETY: Test runs in isolation
922        unsafe {
923            std::env::remove_var("GITHUB_TOKEN");
924            std::env::remove_var("GH_TOKEN");
925        }
926
927        let token = GitHubToolProvider::get_effective_token(None);
928        assert!(token.is_none());
929    }
930
931    #[test]
932    fn test_get_effective_token_github_token_priority() {
933        // SAFETY: Test runs in isolation
934        unsafe {
935            std::env::set_var("GITHUB_TOKEN", "github-token");
936            std::env::set_var("GH_TOKEN", "gh-token");
937        }
938
939        let token = GitHubToolProvider::get_effective_token(Some("runtime-token"));
940        assert_eq!(token, Some("github-token".to_string()));
941
942        // SAFETY: Cleanup
943        unsafe {
944            std::env::remove_var("GITHUB_TOKEN");
945            std::env::remove_var("GH_TOKEN");
946        }
947    }
948
949    #[test]
950    fn test_get_effective_token_gh_token_fallback() {
951        // SAFETY: Test runs in isolation
952        unsafe {
953            std::env::remove_var("GITHUB_TOKEN");
954            std::env::set_var("GH_TOKEN", "gh-token");
955        }
956
957        let token = GitHubToolProvider::get_effective_token(Some("runtime-token"));
958        assert_eq!(token, Some("gh-token".to_string()));
959
960        // SAFETY: Cleanup
961        unsafe {
962            std::env::remove_var("GH_TOKEN");
963        }
964    }
965
966    // ==========================================================================
967    // RateLimitInfo tests
968    // ==========================================================================
969
970    #[test]
971    fn test_rate_limit_info_from_headers() {
972        use reqwest::header::{HeaderMap, HeaderValue};
973
974        let mut headers = HeaderMap::new();
975        headers.insert("x-ratelimit-limit", HeaderValue::from_static("60"));
976        headers.insert("x-ratelimit-remaining", HeaderValue::from_static("0"));
977        headers.insert("x-ratelimit-reset", HeaderValue::from_static("1735689600"));
978
979        let info = RateLimitInfo::from_headers(&headers);
980        assert_eq!(info.limit, Some(60));
981        assert_eq!(info.remaining, Some(0));
982        assert_eq!(info.reset, Some(1_735_689_600));
983        assert!(info.is_exceeded());
984    }
985
986    #[test]
987    fn test_rate_limit_info_not_exceeded() {
988        use reqwest::header::{HeaderMap, HeaderValue};
989
990        let mut headers = HeaderMap::new();
991        headers.insert("x-ratelimit-limit", HeaderValue::from_static("5000"));
992        headers.insert("x-ratelimit-remaining", HeaderValue::from_static("4999"));
993
994        let info = RateLimitInfo::from_headers(&headers);
995        assert!(!info.is_exceeded());
996    }
997
998    #[test]
999    fn test_rate_limit_info_format_status() {
1000        let info = RateLimitInfo {
1001            limit: Some(60),
1002            remaining: Some(0),
1003            reset: None,
1004        };
1005        assert_eq!(
1006            info.format_status(),
1007            Some("0/60 requests remaining".to_string())
1008        );
1009
1010        let info_partial = RateLimitInfo {
1011            limit: Some(60),
1012            remaining: None,
1013            reset: None,
1014        };
1015        assert_eq!(info_partial.format_status(), None);
1016    }
1017
1018    #[test]
1019    fn test_rate_limit_info_empty_headers() {
1020        let headers = reqwest::header::HeaderMap::new();
1021        let info = RateLimitInfo::from_headers(&headers);
1022
1023        assert_eq!(info.limit, None);
1024        assert_eq!(info.remaining, None);
1025        assert_eq!(info.reset, None);
1026        assert!(!info.is_exceeded());
1027    }
1028
1029    #[test]
1030    fn test_rate_limit_info_default() {
1031        let info = RateLimitInfo::default();
1032        assert_eq!(info.limit, None);
1033        assert_eq!(info.remaining, None);
1034        assert_eq!(info.reset, None);
1035        assert!(!info.is_exceeded());
1036    }
1037
1038    #[test]
1039    fn test_rate_limit_info_format_status_missing_remaining() {
1040        let info = RateLimitInfo {
1041            limit: Some(60),
1042            remaining: None,
1043            reset: None,
1044        };
1045        assert!(info.format_status().is_none());
1046    }
1047
1048    #[test]
1049    fn test_rate_limit_info_format_status_missing_limit() {
1050        let info = RateLimitInfo {
1051            limit: None,
1052            remaining: Some(50),
1053            reset: None,
1054        };
1055        assert!(info.format_status().is_none());
1056    }
1057
1058    #[test]
1059    fn test_rate_limit_info_format_reset_duration_none() {
1060        let info = RateLimitInfo {
1061            limit: None,
1062            remaining: None,
1063            reset: None,
1064        };
1065        assert!(info.format_reset_duration().is_none());
1066    }
1067
1068    #[test]
1069    fn test_rate_limit_info_format_reset_duration_past() {
1070        // Use a timestamp in the past
1071        let info = RateLimitInfo {
1072            limit: None,
1073            remaining: None,
1074            reset: Some(0), // epoch
1075        };
1076        // Should return "now" for past timestamps
1077        assert_eq!(info.format_reset_duration(), Some("now".to_string()));
1078    }
1079
1080    #[test]
1081    fn test_rate_limit_info_invalid_header_values() {
1082        use reqwest::header::{HeaderMap, HeaderValue};
1083
1084        let mut headers = HeaderMap::new();
1085        headers.insert(
1086            "x-ratelimit-limit",
1087            HeaderValue::from_static("not-a-number"),
1088        );
1089        headers.insert("x-ratelimit-remaining", HeaderValue::from_static("invalid"));
1090
1091        let info = RateLimitInfo::from_headers(&headers);
1092        assert_eq!(info.limit, None);
1093        assert_eq!(info.remaining, None);
1094    }
1095
1096    // ==========================================================================
1097    // build_api_error tests
1098    // ==========================================================================
1099
1100    #[test]
1101    fn test_build_api_error_rate_limit_exceeded_unauthenticated() {
1102        let rate_limit = RateLimitInfo {
1103            limit: Some(60),
1104            remaining: Some(0),
1105            reset: Some(1_735_689_600),
1106        };
1107
1108        let error = GitHubToolProvider::build_api_error(
1109            reqwest::StatusCode::FORBIDDEN,
1110            &rate_limit,
1111            false,
1112            "release owner/repo v1.0.0",
1113        );
1114
1115        let msg = error.to_string();
1116        assert!(msg.contains("rate limit exceeded"));
1117    }
1118
1119    #[test]
1120    fn test_build_api_error_rate_limit_exceeded_authenticated() {
1121        let rate_limit = RateLimitInfo {
1122            limit: Some(5000),
1123            remaining: Some(0),
1124            reset: None,
1125        };
1126
1127        let error = GitHubToolProvider::build_api_error(
1128            reqwest::StatusCode::FORBIDDEN,
1129            &rate_limit,
1130            true,
1131            "release owner/repo v1.0.0",
1132        );
1133
1134        let msg = error.to_string();
1135        assert!(msg.contains("rate limit exceeded"));
1136    }
1137
1138    #[test]
1139    fn test_build_api_error_forbidden_not_rate_limit() {
1140        let rate_limit = RateLimitInfo {
1141            limit: Some(60),
1142            remaining: Some(30),
1143            reset: None,
1144        };
1145
1146        let error = GitHubToolProvider::build_api_error(
1147            reqwest::StatusCode::FORBIDDEN,
1148            &rate_limit,
1149            false,
1150            "release owner/repo v1.0.0",
1151        );
1152
1153        let msg = error.to_string();
1154        assert!(msg.contains("Access denied"));
1155    }
1156
1157    #[test]
1158    fn test_build_api_error_not_found() {
1159        let rate_limit = RateLimitInfo::default();
1160
1161        let error = GitHubToolProvider::build_api_error(
1162            reqwest::StatusCode::NOT_FOUND,
1163            &rate_limit,
1164            false,
1165            "release owner/repo v999.0.0",
1166        );
1167
1168        let msg = error.to_string();
1169        assert!(msg.contains("not found"));
1170        assert!(msg.contains("404"));
1171    }
1172
1173    #[test]
1174    fn test_build_api_error_unauthorized() {
1175        let rate_limit = RateLimitInfo::default();
1176
1177        let error = GitHubToolProvider::build_api_error(
1178            reqwest::StatusCode::UNAUTHORIZED,
1179            &rate_limit,
1180            true,
1181            "release owner/repo v1.0.0",
1182        );
1183
1184        let msg = error.to_string();
1185        assert!(msg.contains("Authentication failed"));
1186        assert!(msg.contains("401"));
1187    }
1188
1189    #[test]
1190    fn test_build_api_error_server_error() {
1191        let rate_limit = RateLimitInfo::default();
1192
1193        let error = GitHubToolProvider::build_api_error(
1194            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1195            &rate_limit,
1196            false,
1197            "asset download",
1198        );
1199
1200        let msg = error.to_string();
1201        assert!(msg.contains("HTTP 500"));
1202    }
1203
1204    // ==========================================================================
1205    // is_cached tests
1206    // ==========================================================================
1207
1208    #[test]
1209    fn test_is_cached_not_cached() {
1210        let provider = GitHubToolProvider::new();
1211        let temp_dir = TempDir::new().unwrap();
1212        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
1213
1214        let resolved = ResolvedTool {
1215            name: "mytool".to_string(),
1216            version: "1.0.0".to_string(),
1217            platform: Platform::new(Os::Darwin, Arch::Arm64),
1218            source: ToolSource::GitHub {
1219                repo: "owner/repo".to_string(),
1220                tag: "v1.0.0".to_string(),
1221                asset: "mytool.tar.gz".to_string(),
1222                path: None,
1223            },
1224        };
1225
1226        assert!(!provider.is_cached(&resolved, &options));
1227    }
1228
1229    #[test]
1230    fn test_is_cached_cached() {
1231        let provider = GitHubToolProvider::new();
1232        let temp_dir = TempDir::new().unwrap();
1233        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
1234
1235        let resolved = ResolvedTool {
1236            name: "mytool".to_string(),
1237            version: "1.0.0".to_string(),
1238            platform: Platform::new(Os::Darwin, Arch::Arm64),
1239            source: ToolSource::GitHub {
1240                repo: "owner/repo".to_string(),
1241                tag: "v1.0.0".to_string(),
1242                asset: "mytool.tar.gz".to_string(),
1243                path: None,
1244            },
1245        };
1246
1247        // Create the cached file
1248        let cache_dir = provider.tool_cache_dir(&options, "mytool", "1.0.0");
1249        let bin_dir = cache_dir.join("bin");
1250        std::fs::create_dir_all(&bin_dir).unwrap();
1251        std::fs::write(bin_dir.join("mytool"), b"binary").unwrap();
1252
1253        assert!(provider.is_cached(&resolved, &options));
1254    }
1255
1256    // ==========================================================================
1257    // Release and Asset struct tests
1258    // ==========================================================================
1259
1260    #[test]
1261    fn test_release_deserialization() {
1262        let json = r#"{
1263            "tag_name": "v1.0.0",
1264            "assets": [
1265                {"name": "tool-linux.tar.gz", "browser_download_url": "https://example.com/linux.tar.gz"},
1266                {"name": "tool-darwin.tar.gz", "browser_download_url": "https://example.com/darwin.tar.gz"}
1267            ]
1268        }"#;
1269
1270        let release: Release = serde_json::from_str(json).unwrap();
1271        assert_eq!(release.tag_name, "v1.0.0");
1272        assert_eq!(release.assets.len(), 2);
1273        assert_eq!(release.assets[0].name, "tool-linux.tar.gz");
1274        assert_eq!(
1275            release.assets[0].browser_download_url,
1276            "https://example.com/linux.tar.gz"
1277        );
1278    }
1279
1280    #[test]
1281    fn test_release_deserialization_empty_assets() {
1282        let json = r#"{"tag_name": "v0.1.0", "assets": []}"#;
1283        let release: Release = serde_json::from_str(json).unwrap();
1284        assert!(release.assets.is_empty());
1285    }
1286}