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, pkg)
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, ToolExtract, ToolOptions, ToolProvider,
12    ToolResolveRequest, ToolSource,
13};
14use flate2::read::GzDecoder;
15use reqwest::Client;
16use serde::Deserialize;
17use sha2::{Digest, Sha256};
18#[cfg(target_os = "macos")]
19use std::fs::File;
20use std::io::{Cursor, Read};
21use std::panic::{AssertUnwindSafe, catch_unwind};
22use std::path::{Path, PathBuf};
23#[cfg(target_os = "macos")]
24use std::process::{Command, Stdio};
25use tar::Archive;
26#[cfg(target_os = "macos")]
27use tempfile::Builder;
28use tokio::io::AsyncReadExt;
29use tracing::{debug, info};
30
31/// Rate limit information from GitHub API response headers.
32#[derive(Debug, Default)]
33struct RateLimitInfo {
34    /// Maximum requests allowed per hour.
35    limit: Option<u32>,
36    /// Remaining requests in the current window.
37    remaining: Option<u32>,
38    /// Unix timestamp when the rate limit resets.
39    reset: Option<u64>,
40}
41
42impl RateLimitInfo {
43    /// Extract rate limit info from response headers.
44    fn from_headers(headers: &reqwest::header::HeaderMap) -> Self {
45        Self {
46            limit: headers
47                .get("x-ratelimit-limit")
48                .and_then(|v| v.to_str().ok())
49                .and_then(|s| s.parse().ok()),
50            remaining: headers
51                .get("x-ratelimit-remaining")
52                .and_then(|v| v.to_str().ok())
53                .and_then(|s| s.parse().ok()),
54            reset: headers
55                .get("x-ratelimit-reset")
56                .and_then(|v| v.to_str().ok())
57                .and_then(|s| s.parse().ok()),
58        }
59    }
60
61    /// Check if the rate limit has been exceeded.
62    fn is_exceeded(&self) -> bool {
63        self.remaining == Some(0)
64    }
65
66    /// Format the reset time as a human-readable duration.
67    fn format_reset_duration(&self) -> Option<String> {
68        let reset_ts = self.reset?;
69        let now = std::time::SystemTime::now()
70            .duration_since(std::time::UNIX_EPOCH)
71            .ok()?
72            .as_secs();
73
74        if reset_ts <= now {
75            return Some("now".to_string());
76        }
77
78        let seconds_remaining = reset_ts - now;
79        let minutes = seconds_remaining / 60;
80        let hours = minutes / 60;
81
82        if hours > 0 {
83            Some(format!("{} hour(s) {} minute(s)", hours, minutes % 60))
84        } else if minutes > 0 {
85            Some(format!("{} minute(s)", minutes))
86        } else {
87            Some(format!("{} second(s)", seconds_remaining))
88        }
89    }
90
91    /// Format rate limit status (e.g., "0/60 requests remaining").
92    fn format_status(&self) -> Option<String> {
93        match (self.remaining, self.limit) {
94            (Some(remaining), Some(limit)) => {
95                Some(format!("{}/{} requests remaining", remaining, limit))
96            }
97            _ => None,
98        }
99    }
100}
101
102/// GitHub release metadata from the API.
103#[derive(Debug, Deserialize)]
104struct Release {
105    #[allow(dead_code)] // Deserialized from GitHub API response
106    tag_name: String,
107    assets: Vec<Asset>,
108}
109
110/// GitHub release asset.
111#[derive(Debug, Deserialize)]
112struct Asset {
113    name: String,
114    browser_download_url: String,
115}
116
117/// Tool provider for GitHub Releases.
118///
119/// Fetches binaries from GitHub Releases, supporting template expansion
120/// for platform-specific asset names.
121pub struct GitHubToolProvider {
122    client: Client,
123}
124
125impl Default for GitHubToolProvider {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl GitHubToolProvider {
132    fn build_client() -> Client {
133        let primary = catch_unwind(AssertUnwindSafe(|| {
134            Client::builder().user_agent("cuenv").build()
135        }));
136
137        match primary {
138            Ok(Ok(client)) => client,
139            Ok(Err(primary_err)) => Client::builder()
140                .user_agent("cuenv")
141                .no_proxy()
142                .build()
143                .unwrap_or_else(|fallback_err| {
144                    panic!(
145                        "Failed to create GitHub HTTP client: primary={primary_err}; fallback={fallback_err}"
146                    )
147                }),
148            Err(_) => Client::builder()
149                .user_agent("cuenv")
150                .no_proxy()
151                .build()
152                .expect(
153                    "Failed to create GitHub HTTP client after system proxy discovery panicked",
154                ),
155        }
156    }
157
158    /// Create a new GitHub tool provider.
159    #[must_use]
160    pub fn new() -> Self {
161        Self {
162            client: Self::build_client(),
163        }
164    }
165
166    /// Get the cache directory for a tool.
167    fn tool_cache_dir(&self, options: &ToolOptions, name: &str, version: &str) -> PathBuf {
168        options.cache_dir().join("github").join(name).join(version)
169    }
170
171    /// Expand template variables in a string.
172    fn expand_template(&self, template: &str, version: &str, platform: &Platform) -> String {
173        let os_str = match platform.os {
174            Os::Darwin => "darwin",
175            Os::Linux => "linux",
176        };
177        let arch_str = match platform.arch {
178            Arch::Arm64 => "aarch64",
179            Arch::X86_64 => "x86_64",
180        };
181
182        template
183            .replace("{version}", version)
184            .replace("{os}", os_str)
185            .replace("{arch}", arch_str)
186    }
187
188    /// Get the effective token: GITHUB_TOKEN > GH_TOKEN > runtime token
189    fn get_effective_token(runtime_token: Option<&str>) -> Option<String> {
190        std::env::var("GITHUB_TOKEN")
191            .ok()
192            .or_else(|| std::env::var("GH_TOKEN").ok())
193            .or_else(|| runtime_token.map(String::from))
194    }
195
196    /// Fetch release information from GitHub API.
197    async fn fetch_release(&self, repo: &str, tag: &str, token: Option<&str>) -> Result<Release> {
198        let url = format!(
199            "https://api.github.com/repos/{}/releases/tags/{}",
200            repo, tag
201        );
202        debug!(%url, "Fetching GitHub release");
203
204        let effective_token = Self::get_effective_token(token);
205        let is_authenticated = effective_token.is_some();
206
207        let mut request = self.client.get(&url);
208        if let Some(token) = effective_token {
209            request = request.header("Authorization", format!("Bearer {}", token));
210        }
211
212        let response = request.send().await.map_err(|e| {
213            cuenv_core::Error::tool_resolution(format!("Failed to fetch release: {}", e))
214        })?;
215
216        let status = response.status();
217        if !status.is_success() {
218            let rate_limit = RateLimitInfo::from_headers(response.headers());
219
220            return Err(Self::build_api_error(
221                status,
222                &rate_limit,
223                is_authenticated,
224                &format!("release {} {}", repo, tag),
225            ));
226        }
227
228        response.json().await.map_err(|e| {
229            cuenv_core::Error::tool_resolution(format!("Failed to parse release: {}", e))
230        })
231    }
232
233    /// Build an appropriate error for GitHub API failures.
234    fn build_api_error(
235        status: reqwest::StatusCode,
236        rate_limit: &RateLimitInfo,
237        is_authenticated: bool,
238        resource: &str,
239    ) -> cuenv_core::Error {
240        // Handle rate limit exceeded (403 with remaining=0)
241        if status == reqwest::StatusCode::FORBIDDEN && rate_limit.is_exceeded() {
242            let mut message = "GitHub API rate limit exceeded".to_string();
243
244            if let Some(status_str) = rate_limit.format_status() {
245                message.push_str(&format!(" ({})", status_str));
246            }
247            if let Some(reset_str) = rate_limit.format_reset_duration() {
248                message.push_str(&format!(". Resets in {}", reset_str));
249            }
250
251            let help = if is_authenticated {
252                "Wait for the rate limit to reset, or use a different GitHub token"
253            } else {
254                "Set GITHUB_TOKEN environment variable with `public_repo` scope \
255                 for 5000 requests/hour (unauthenticated: 60/hour)"
256            };
257
258            return cuenv_core::Error::tool_resolution_with_help(message, help);
259        }
260
261        // Handle 403 Forbidden (not rate limited)
262        if status == reqwest::StatusCode::FORBIDDEN {
263            let message = format!("Access denied to {} (HTTP 403 Forbidden)", resource);
264            let help = if is_authenticated {
265                "Check that your GITHUB_TOKEN has the required permissions. \
266                 For private repositories, ensure the token has the `repo` scope"
267            } else {
268                "Set GITHUB_TOKEN environment variable with `public_repo` scope to access this resource"
269            };
270            return cuenv_core::Error::tool_resolution_with_help(message, help);
271        }
272
273        // Handle 404 Not Found
274        if status == reqwest::StatusCode::NOT_FOUND {
275            return cuenv_core::Error::tool_resolution(format!(
276                "{} not found (HTTP 404)",
277                resource
278            ));
279        }
280
281        // Handle 401 Unauthorized
282        if status == reqwest::StatusCode::UNAUTHORIZED {
283            let help = "Your GITHUB_TOKEN may be invalid or expired. \
284                       Generate a new token at https://github.com/settings/tokens";
285            return cuenv_core::Error::tool_resolution_with_help(
286                format!("Authentication failed for {} (HTTP 401)", resource),
287                help,
288            );
289        }
290
291        // Generic error for other status codes
292        cuenv_core::Error::tool_resolution(format!("Failed to fetch {}: HTTP {}", resource, status))
293    }
294
295    /// Download an asset from GitHub.
296    async fn download_asset(&self, url: &str, token: Option<&str>) -> Result<Vec<u8>> {
297        debug!(%url, "Downloading GitHub asset");
298
299        let effective_token = Self::get_effective_token(token);
300        let is_authenticated = effective_token.is_some();
301
302        let mut request = self.client.get(url);
303        if let Some(token) = effective_token {
304            request = request.header("Authorization", format!("Bearer {}", token));
305        }
306
307        let response = request.send().await.map_err(|e| {
308            cuenv_core::Error::tool_resolution(format!("Failed to download asset: {}", e))
309        })?;
310
311        let status = response.status();
312        if !status.is_success() {
313            let rate_limit = RateLimitInfo::from_headers(response.headers());
314            return Err(Self::build_api_error(
315                status,
316                &rate_limit,
317                is_authenticated,
318                "asset download",
319            ));
320        }
321
322        response
323            .bytes()
324            .await
325            .map(|b| b.to_vec())
326            .map_err(|e| cuenv_core::Error::tool_resolution(format!("Failed to read asset: {}", e)))
327    }
328
329    /// Extract a binary from an archive.
330    fn extract_binary(
331        &self,
332        data: &[u8],
333        asset_name: &str,
334        binary_path: Option<&str>,
335        dest: &std::path::Path,
336    ) -> Result<PathBuf> {
337        // Determine archive type
338        let is_zip = asset_name.ends_with(".zip");
339        let is_tar_gz = asset_name.ends_with(".tar.gz") || asset_name.ends_with(".tgz");
340        let is_pkg = asset_name.ends_with(".pkg");
341
342        if is_zip {
343            self.extract_from_zip(data, binary_path, dest)
344        } else if is_tar_gz {
345            self.extract_from_tar_gz(data, binary_path, dest)
346        } else if is_pkg {
347            self.extract_from_pkg(data, binary_path, dest)
348        } else {
349            // Assume it's a raw binary
350            std::fs::create_dir_all(dest)?;
351            let binary_name = std::path::Path::new(asset_name)
352                .file_stem()
353                .and_then(|s| s.to_str())
354                .unwrap_or(asset_name);
355            let binary_dest = dest.join(binary_name);
356            std::fs::write(&binary_dest, data)?;
357
358            #[cfg(unix)]
359            {
360                use std::os::unix::fs::PermissionsExt;
361                let mut perms = std::fs::metadata(&binary_dest)?.permissions();
362                perms.set_mode(0o755);
363                std::fs::set_permissions(&binary_dest, perms)?;
364            }
365
366            Ok(binary_dest)
367        }
368    }
369
370    /// Extract from a zip archive.
371    ///
372    /// Uses a temporary directory for atomic extraction - if extraction fails
373    /// partway through, no partial files are left in the destination.
374    fn extract_from_zip(
375        &self,
376        data: &[u8],
377        binary_path: Option<&str>,
378        dest: &std::path::Path,
379    ) -> Result<PathBuf> {
380        let cursor = Cursor::new(data);
381        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| {
382            cuenv_core::Error::tool_resolution(format!("Failed to open zip: {}", e))
383        })?;
384
385        // If a specific path is requested, extract just that file (no temp dir needed)
386        if let Some(path) = binary_path {
387            for i in 0..archive.len() {
388                let mut file = archive.by_index(i).map_err(|e| {
389                    cuenv_core::Error::tool_resolution(format!("Failed to read zip entry: {}", e))
390                })?;
391
392                let name = file.name().to_string();
393                if name.ends_with(path) || name == path {
394                    std::fs::create_dir_all(dest)?;
395                    let file_name = std::path::Path::new(&name)
396                        .file_name()
397                        .and_then(|s| s.to_str())
398                        .unwrap_or(path);
399                    let dest_path = dest.join(file_name);
400
401                    let mut content = Vec::new();
402                    file.read_to_end(&mut content)?;
403                    std::fs::write(&dest_path, &content)?;
404
405                    #[cfg(unix)]
406                    {
407                        use std::os::unix::fs::PermissionsExt;
408                        let mut perms = std::fs::metadata(&dest_path)?.permissions();
409                        perms.set_mode(0o755);
410                        std::fs::set_permissions(&dest_path, perms)?;
411                    }
412
413                    return Ok(dest_path);
414                }
415            }
416
417            return Err(cuenv_core::Error::tool_resolution(format!(
418                "Binary '{}' not found in archive",
419                path
420            )));
421        }
422
423        // Extract all files to a temp directory first for atomic operation
424        let temp_dir = dest.with_file_name(format!(
425            ".{}.tmp",
426            dest.file_name()
427                .and_then(|s| s.to_str())
428                .unwrap_or("extract")
429        ));
430
431        // Clean up any previous failed extraction
432        if temp_dir.exists() {
433            std::fs::remove_dir_all(&temp_dir)?;
434        }
435        std::fs::create_dir_all(&temp_dir)?;
436
437        // Extract to temp directory
438        let extract_result = (|| -> Result<()> {
439            for i in 0..archive.len() {
440                let mut file = archive.by_index(i).map_err(|e| {
441                    cuenv_core::Error::tool_resolution(format!("Failed to read zip entry: {}", e))
442                })?;
443
444                let outpath = match file.enclosed_name() {
445                    Some(path) => temp_dir.join(path),
446                    None => continue,
447                };
448
449                if file.is_dir() {
450                    std::fs::create_dir_all(&outpath)?;
451                } else {
452                    if let Some(p) = outpath.parent() {
453                        std::fs::create_dir_all(p)?;
454                    }
455                    let mut content = Vec::new();
456                    file.read_to_end(&mut content)?;
457                    std::fs::write(&outpath, &content)?;
458
459                    #[cfg(unix)]
460                    if let Some(mode) = file.unix_mode() {
461                        use std::os::unix::fs::PermissionsExt;
462                        let mut perms = std::fs::metadata(&outpath)?.permissions();
463                        perms.set_mode(mode);
464                        std::fs::set_permissions(&outpath, perms)?;
465                    }
466                }
467            }
468            Ok(())
469        })();
470
471        // On failure, clean up temp directory
472        if let Err(e) = extract_result {
473            let _ = std::fs::remove_dir_all(&temp_dir);
474            return Err(e);
475        }
476
477        // Atomic move: remove destination if exists, then rename temp to dest
478        if dest.exists() {
479            std::fs::remove_dir_all(dest)?;
480        }
481        std::fs::rename(&temp_dir, dest)?;
482
483        // Find the main binary (first executable in bin/ or root)
484        self.find_main_binary(dest)
485    }
486
487    /// Extract from a tar.gz archive.
488    fn extract_from_tar_gz(
489        &self,
490        data: &[u8],
491        binary_path: Option<&str>,
492        dest: &std::path::Path,
493    ) -> Result<PathBuf> {
494        let cursor = Cursor::new(data);
495        let decoder = GzDecoder::new(cursor);
496        let mut archive = Archive::new(decoder);
497
498        std::fs::create_dir_all(dest)?;
499
500        if let Some(path) = binary_path {
501            // Look for specific file
502            for entry in archive.entries().map_err(|e| {
503                cuenv_core::Error::tool_resolution(format!("Failed to read tar: {}", e))
504            })? {
505                let mut entry = entry.map_err(|e| {
506                    cuenv_core::Error::tool_resolution(format!("Failed to read tar entry: {}", e))
507                })?;
508
509                let entry_path = entry.path().map_err(|e| {
510                    cuenv_core::Error::tool_resolution(format!("Invalid path in tar: {}", e))
511                })?;
512
513                let path_str = entry_path.to_string_lossy();
514                if path_str.ends_with(path) || path_str.as_ref() == path {
515                    let file_name = std::path::Path::new(path)
516                        .file_name()
517                        .and_then(|s| s.to_str())
518                        .unwrap_or(path);
519                    let dest_path = dest.join(file_name);
520
521                    let mut content = Vec::new();
522                    entry.read_to_end(&mut content)?;
523                    std::fs::write(&dest_path, &content)?;
524
525                    #[cfg(unix)]
526                    {
527                        use std::os::unix::fs::PermissionsExt;
528                        let mut perms = std::fs::metadata(&dest_path)?.permissions();
529                        perms.set_mode(0o755);
530                        std::fs::set_permissions(&dest_path, perms)?;
531                    }
532
533                    return Ok(dest_path);
534                }
535            }
536
537            return Err(cuenv_core::Error::tool_resolution(format!(
538                "Binary '{}' not found in archive",
539                path
540            )));
541        }
542
543        // Extract all files
544        archive.unpack(dest).map_err(|e| {
545            cuenv_core::Error::tool_resolution(format!("Failed to extract tar: {}", e))
546        })?;
547
548        // Find the main binary
549        self.find_main_binary(dest)
550    }
551
552    /// Extract from a macOS .pkg archive.
553    #[cfg(target_os = "macos")]
554    fn extract_from_pkg(
555        &self,
556        data: &[u8],
557        binary_path: Option<&str>,
558        dest: &std::path::Path,
559    ) -> Result<PathBuf> {
560        std::fs::create_dir_all(dest)?;
561
562        let work_dir = Builder::new().prefix("cuenv-pkg-").tempdir().map_err(|e| {
563            cuenv_core::Error::tool_resolution(format!(
564                "Failed to create temporary directory for pkg extraction: {}",
565                e
566            ))
567        })?;
568
569        let pkg_path = work_dir.path().join("asset.pkg");
570        std::fs::write(&pkg_path, data)?;
571
572        let expanded_dir = work_dir.path().join("expanded");
573        Self::run_command(
574            Command::new("pkgutil")
575                .arg("--expand")
576                .arg(&pkg_path)
577                .arg(&expanded_dir),
578            "expand pkg archive",
579        )?;
580
581        let payloads = Self::collect_payload_files(&expanded_dir)?;
582        if payloads.is_empty() {
583            return Err(cuenv_core::Error::tool_resolution(
584                "No payload files found in pkg archive".to_string(),
585            ));
586        }
587
588        for (index, payload_path) in payloads.iter().enumerate() {
589            let payload_dir = work_dir.path().join(format!("payload-{index}"));
590            std::fs::create_dir_all(&payload_dir)?;
591
592            let payload_file = File::open(payload_path)?;
593            let payload_extract = Self::run_command(
594                Command::new("cpio")
595                    .args(["-idm", "--quiet"])
596                    .current_dir(&payload_dir)
597                    .stdin(Stdio::from(payload_file)),
598                "extract pkg payload",
599            );
600
601            if let Err(error) = payload_extract {
602                debug!(?payload_path, %error, "Skipping unreadable pkg payload");
603                continue;
604            }
605
606            if let Some(path) = binary_path {
607                if let Some(found) = Self::find_path_in_tree(&payload_dir, path)? {
608                    return Self::copy_extracted_file(&found, dest, path);
609                }
610            } else if let Ok(found) = self.find_main_binary(&payload_dir) {
611                return Self::copy_extracted_file(&found, dest, "binary");
612            }
613        }
614
615        if let Some(path) = binary_path {
616            return Err(cuenv_core::Error::tool_resolution(format!(
617                "Binary '{}' not found in pkg payloads",
618                path
619            )));
620        }
621
622        Err(cuenv_core::Error::tool_resolution(
623            "No executable found in pkg payloads".to_string(),
624        ))
625    }
626
627    /// Extract from a macOS .pkg archive (unsupported on non-macOS hosts).
628    #[cfg(not(target_os = "macos"))]
629    fn extract_from_pkg(
630        &self,
631        _data: &[u8],
632        _binary_path: Option<&str>,
633        _dest: &std::path::Path,
634    ) -> Result<PathBuf> {
635        Err(cuenv_core::Error::tool_resolution(
636            ".pkg extraction is only supported on macOS hosts".to_string(),
637        ))
638    }
639
640    /// Copy a selected extracted file into the destination directory.
641    #[cfg(target_os = "macos")]
642    fn copy_extracted_file(source: &Path, dest: &Path, fallback_name: &str) -> Result<PathBuf> {
643        std::fs::create_dir_all(dest)?;
644        let file_name = source
645            .file_name()
646            .and_then(|s| s.to_str())
647            .unwrap_or(fallback_name);
648        let dest_path = dest.join(file_name);
649        std::fs::copy(source, &dest_path)?;
650        Self::ensure_executable(&dest_path)?;
651        Ok(dest_path)
652    }
653
654    /// Run a process and map non-zero exits to tool-resolution errors.
655    #[cfg(target_os = "macos")]
656    fn run_command(command: &mut Command, action: &str) -> Result<()> {
657        let status = command.status().map_err(|e| {
658            cuenv_core::Error::tool_resolution(format!("Failed to {}: {}", action, e))
659        })?;
660
661        if status.success() {
662            Ok(())
663        } else {
664            Err(cuenv_core::Error::tool_resolution(format!(
665                "Failed to {}: {}",
666                action, status
667            )))
668        }
669    }
670
671    /// Recursively collect all `Payload` files from an expanded pkg directory.
672    #[cfg(target_os = "macos")]
673    fn collect_payload_files(root: &Path) -> Result<Vec<PathBuf>> {
674        let mut stack = vec![root.to_path_buf()];
675        let mut payloads = Vec::new();
676
677        while let Some(current) = stack.pop() {
678            for entry in std::fs::read_dir(&current)? {
679                let entry = entry?;
680                let path = entry.path();
681                if path.is_dir() {
682                    stack.push(path);
683                    continue;
684                }
685
686                if path.is_file() && path.file_name().and_then(|n| n.to_str()) == Some("Payload") {
687                    payloads.push(path);
688                }
689            }
690        }
691
692        Ok(payloads)
693    }
694
695    /// Find a file in a directory tree matching the requested pkg path.
696    #[cfg(target_os = "macos")]
697    fn find_path_in_tree(root: &Path, path: &str) -> Result<Option<PathBuf>> {
698        let requested = Self::normalize_lookup_path(path);
699        let mut stack = vec![root.to_path_buf()];
700
701        while let Some(current) = stack.pop() {
702            for entry in std::fs::read_dir(&current)? {
703                let entry = entry?;
704                let entry_path = entry.path();
705
706                if entry_path.is_dir() {
707                    stack.push(entry_path);
708                    continue;
709                }
710
711                if !entry_path.is_file() {
712                    continue;
713                }
714
715                let Ok(relative) = entry_path.strip_prefix(root) else {
716                    continue;
717                };
718                let candidate = relative.to_string_lossy().replace('\\', "/");
719                let candidate = candidate.trim_start_matches("./");
720
721                if candidate == requested || candidate.ends_with(&format!("/{requested}")) {
722                    return Ok(Some(entry_path));
723                }
724            }
725        }
726
727        Ok(None)
728    }
729
730    /// Normalize lookup paths for suffix matching.
731    #[cfg(target_os = "macos")]
732    fn normalize_lookup_path(path: &str) -> String {
733        path.trim_start_matches('/')
734            .trim_start_matches("./")
735            .to_string()
736    }
737
738    /// Determine whether a path looks like a dynamic library.
739    fn path_looks_like_library(path: &str) -> bool {
740        let path_lower = path.to_ascii_lowercase();
741        path_lower.ends_with(".dylib")
742            || path_lower.ends_with(".so")
743            || path_lower.contains(".so.")
744            || path_lower.ends_with(".dll")
745    }
746
747    /// Determine whether a filesystem path looks like a dynamic library.
748    fn file_looks_like_library(path: &Path) -> bool {
749        let name = path
750            .file_name()
751            .and_then(|n| n.to_str())
752            .unwrap_or_default()
753            .to_ascii_lowercase();
754        name.ends_with(".dylib")
755            || name.ends_with(".so")
756            || name.contains(".so.")
757            || name.ends_with(".dll")
758    }
759
760    fn expand_extract_templates(
761        &self,
762        extract: &[ToolExtract],
763        version: &str,
764        platform: &Platform,
765    ) -> Vec<ToolExtract> {
766        extract
767            .iter()
768            .map(|item| match item {
769                ToolExtract::Bin { path, as_name } => ToolExtract::Bin {
770                    path: self.expand_template(path, version, platform),
771                    as_name: as_name.clone(),
772                },
773                ToolExtract::Lib { path, env } => ToolExtract::Lib {
774                    path: self.expand_template(path, version, platform),
775                    env: env.clone(),
776                },
777                ToolExtract::Include { path } => ToolExtract::Include {
778                    path: self.expand_template(path, version, platform),
779                },
780                ToolExtract::PkgConfig { path } => ToolExtract::PkgConfig {
781                    path: self.expand_template(path, version, platform),
782                },
783                ToolExtract::File { path, env } => ToolExtract::File {
784                    path: self.expand_template(path, version, platform),
785                    env: env.clone(),
786                },
787            })
788            .collect()
789    }
790
791    fn cache_targets_from_source(
792        &self,
793        resolved: &ResolvedTool,
794        options: &ToolOptions,
795    ) -> Vec<PathBuf> {
796        let cache_dir = self.tool_cache_dir(options, &resolved.name, &resolved.version);
797        let extract = match &resolved.source {
798            ToolSource::GitHub { extract, .. } => extract,
799            _ => return vec![cache_dir.join("bin").join(&resolved.name)],
800        };
801
802        if extract.is_empty() {
803            return vec![cache_dir.join("bin").join(&resolved.name)];
804        }
805
806        extract
807            .iter()
808            .map(|item| self.cache_target_for_extract(&cache_dir, &resolved.name, item))
809            .collect()
810    }
811
812    fn cache_target_for_extract(
813        &self,
814        cache_dir: &Path,
815        tool_name: &str,
816        item: &ToolExtract,
817    ) -> PathBuf {
818        match item {
819            ToolExtract::Bin { path, as_name } => {
820                let name = as_name.as_deref().unwrap_or_else(|| {
821                    Path::new(path)
822                        .file_name()
823                        .and_then(|n| n.to_str())
824                        .unwrap_or(tool_name)
825                });
826                cache_dir.join("bin").join(name)
827            }
828            ToolExtract::Lib { path, .. } => {
829                let name = Path::new(path)
830                    .file_name()
831                    .and_then(|n| n.to_str())
832                    .unwrap_or(tool_name);
833                cache_dir.join("lib").join(name)
834            }
835            ToolExtract::Include { path } => {
836                let name = Path::new(path)
837                    .file_name()
838                    .and_then(|n| n.to_str())
839                    .unwrap_or(tool_name);
840                cache_dir.join("include").join(name)
841            }
842            ToolExtract::PkgConfig { path } => {
843                let name = Path::new(path)
844                    .file_name()
845                    .and_then(|n| n.to_str())
846                    .unwrap_or(tool_name);
847                cache_dir.join("lib").join("pkgconfig").join(name)
848            }
849            ToolExtract::File { path, .. } => {
850                let name = Path::new(path)
851                    .file_name()
852                    .and_then(|n| n.to_str())
853                    .unwrap_or(tool_name);
854                cache_dir.join("files").join(name)
855            }
856        }
857    }
858
859    fn is_executable_extract(item: &ToolExtract) -> bool {
860        matches!(item, ToolExtract::Bin { .. })
861    }
862
863    fn extract_source_path(item: &ToolExtract) -> &str {
864        match item {
865            ToolExtract::Bin { path, .. }
866            | ToolExtract::Lib { path, .. }
867            | ToolExtract::Include { path }
868            | ToolExtract::PkgConfig { path }
869            | ToolExtract::File { path, .. } => path,
870        }
871    }
872
873    /// Ensure a file is executable on Unix hosts.
874    fn ensure_executable(path: &Path) -> Result<()> {
875        #[cfg(unix)]
876        {
877            use std::os::unix::fs::PermissionsExt;
878            let mut perms = std::fs::metadata(path)?.permissions();
879            perms.set_mode(0o755);
880            std::fs::set_permissions(path, perms)?;
881        }
882
883        Ok(())
884    }
885
886    /// Find the main binary in an extracted directory.
887    fn find_main_binary(&self, dir: &std::path::Path) -> Result<PathBuf> {
888        // First, look for binaries in bin/
889        let bin_dir = dir.join("bin");
890        if bin_dir.exists() {
891            for entry in std::fs::read_dir(&bin_dir)? {
892                let entry = entry?;
893                let path = entry.path();
894                if path.is_file() {
895                    return Ok(path);
896                }
897            }
898        }
899
900        // Then look for executables in root
901        for entry in std::fs::read_dir(dir)? {
902            let entry = entry?;
903            let path = entry.path();
904
905            if path.is_file() {
906                #[cfg(unix)]
907                {
908                    use std::os::unix::fs::PermissionsExt;
909                    if let Ok(meta) = std::fs::metadata(&path) {
910                        if meta.permissions().mode() & 0o111 != 0 {
911                            return Ok(path);
912                        }
913                    }
914                }
915                #[cfg(not(unix))]
916                {
917                    // On non-Unix, just return the first file
918                    return Ok(path);
919                }
920            }
921        }
922
923        Err(cuenv_core::Error::tool_resolution(
924            "No binary found in extracted archive".to_string(),
925        ))
926    }
927}
928
929#[async_trait]
930impl ToolProvider for GitHubToolProvider {
931    fn name(&self) -> &'static str {
932        "github"
933    }
934
935    fn description(&self) -> &'static str {
936        "Fetch tools from GitHub Releases"
937    }
938
939    fn can_handle(&self, source: &ToolSource) -> bool {
940        matches!(source, ToolSource::GitHub { .. })
941    }
942
943    async fn resolve(&self, request: &ToolResolveRequest<'_>) -> Result<ResolvedTool> {
944        let tool_name = request.tool_name;
945        let version = request.version;
946        let platform = request.platform;
947        let config = request.config;
948        let token = request.token;
949
950        let repo = config
951            .get("repo")
952            .and_then(|v| v.as_str())
953            .ok_or_else(|| cuenv_core::Error::tool_resolution("Missing 'repo' in config"))?;
954
955        let asset_template = config
956            .get("asset")
957            .and_then(|v| v.as_str())
958            .ok_or_else(|| cuenv_core::Error::tool_resolution("Missing 'asset' in config"))?;
959
960        let tag_template = config
961            .get("tag")
962            .and_then(|v| v.as_str())
963            .map(String::from)
964            .unwrap_or_else(|| {
965                let prefix = config
966                    .get("tagPrefix")
967                    .and_then(|v| v.as_str())
968                    .unwrap_or("");
969                format!("{prefix}{{version}}")
970            });
971
972        let extract: Vec<ToolExtract> = config
973            .get("extract")
974            .cloned()
975            .map(serde_json::from_value)
976            .transpose()
977            .map_err(|e| {
978                cuenv_core::Error::tool_resolution(format!(
979                    "Invalid 'extract' in GitHub source config: {}",
980                    e
981                ))
982            })?
983            .unwrap_or_default();
984        let path = config
985            .get("path")
986            .and_then(|v| v.as_str())
987            .map(String::from);
988
989        info!(%tool_name, %repo, %version, %platform, "Resolving GitHub release");
990
991        // Expand templates
992        let tag = self.expand_template(&tag_template, version, platform);
993        let asset = self.expand_template(asset_template, version, platform);
994        let mut expanded_extract = self.expand_extract_templates(&extract, version, platform);
995        if expanded_extract.is_empty()
996            && let Some(path) = path.as_deref()
997        {
998            let expanded_path = self.expand_template(path, version, platform);
999            if Self::path_looks_like_library(&expanded_path) {
1000                expanded_extract.push(ToolExtract::Lib {
1001                    path: expanded_path,
1002                    env: None,
1003                });
1004            } else {
1005                expanded_extract.push(ToolExtract::Bin {
1006                    path: expanded_path,
1007                    as_name: None,
1008                });
1009            }
1010        }
1011
1012        // Fetch release to verify it exists (uses runtime token if provided)
1013        let release = self.fetch_release(repo, &tag, token).await?;
1014
1015        // Find the asset
1016        let found_asset = release.assets.iter().find(|a| a.name == asset);
1017        if found_asset.is_none() {
1018            let available: Vec<_> = release.assets.iter().map(|a| &a.name).collect();
1019            return Err(cuenv_core::Error::tool_resolution(format!(
1020                "Asset '{}' not found in release. Available: {:?}",
1021                asset, available
1022            )));
1023        }
1024
1025        debug!(%tag, %asset, "Resolved GitHub release");
1026
1027        Ok(ResolvedTool {
1028            name: tool_name.to_string(),
1029            version: version.to_string(),
1030            platform: platform.clone(),
1031            source: ToolSource::GitHub {
1032                repo: repo.to_string(),
1033                tag,
1034                asset,
1035                extract: expanded_extract,
1036            },
1037        })
1038    }
1039
1040    async fn fetch(&self, resolved: &ResolvedTool, options: &ToolOptions) -> Result<FetchedTool> {
1041        let ToolSource::GitHub {
1042            repo,
1043            tag,
1044            asset,
1045            extract,
1046        } = &resolved.source
1047        else {
1048            return Err(cuenv_core::Error::tool_resolution(
1049                "GitHubToolProvider received non-GitHub source".to_string(),
1050            ));
1051        };
1052
1053        info!(
1054            tool = %resolved.name,
1055            %repo,
1056            %tag,
1057            %asset,
1058            "Fetching GitHub release"
1059        );
1060
1061        // Check cache
1062        let cache_dir = self.tool_cache_dir(options, &resolved.name, &resolved.version);
1063        let cached_targets = self.cache_targets_from_source(resolved, options);
1064        if !options.force_refetch && cached_targets.iter().all(|p| p.exists()) {
1065            let cached_path = cached_targets
1066                .first()
1067                .cloned()
1068                .unwrap_or_else(|| cache_dir.join("bin").join(&resolved.name));
1069            debug!(?cached_path, "Tool already cached");
1070            let sha256 = compute_file_sha256(&cached_path).await?;
1071            return Ok(FetchedTool {
1072                name: resolved.name.clone(),
1073                binary_path: cached_path,
1074                sha256,
1075            });
1076        }
1077
1078        // Fetch release and download asset (no runtime token for fetch - uses env vars)
1079        let release = self.fetch_release(repo, tag, None).await?;
1080        let found_asset = release
1081            .assets
1082            .iter()
1083            .find(|a| &a.name == asset)
1084            .ok_or_else(|| {
1085                cuenv_core::Error::tool_resolution(format!("Asset '{}' not found", asset))
1086            })?;
1087
1088        let data = self
1089            .download_asset(&found_asset.browser_download_url, None)
1090            .await?;
1091
1092        if extract.is_empty() {
1093            // Legacy behavior: single binary inferred from archive.
1094            let extracted = self.extract_binary(&data, asset, None, &cache_dir)?;
1095            let final_path = if Self::file_looks_like_library(&extracted) {
1096                let file_name = extracted
1097                    .file_name()
1098                    .and_then(|n| n.to_str())
1099                    .unwrap_or(&resolved.name);
1100                cache_dir.join("lib").join(file_name)
1101            } else {
1102                cache_dir.join("bin").join(&resolved.name)
1103            };
1104            if let Some(parent) = final_path.parent() {
1105                std::fs::create_dir_all(parent)?;
1106            }
1107            if extracted != final_path {
1108                if final_path.exists() {
1109                    std::fs::remove_file(&final_path)?;
1110                }
1111                std::fs::rename(&extracted, &final_path)?;
1112            }
1113            if !Self::file_looks_like_library(&final_path) {
1114                Self::ensure_executable(&final_path)?;
1115            }
1116
1117            let sha256 = compute_file_sha256(&final_path).await?;
1118            info!(
1119                tool = %resolved.name,
1120                binary = ?final_path,
1121                %sha256,
1122                "Fetched GitHub release"
1123            );
1124            return Ok(FetchedTool {
1125                name: resolved.name.clone(),
1126                binary_path: final_path,
1127                sha256,
1128            });
1129        }
1130
1131        // Typed extract mode: fetch each declared artifact by path.
1132        let extract_dir = cache_dir.join(".extract");
1133        if extract_dir.exists() {
1134            std::fs::remove_dir_all(&extract_dir)?;
1135        }
1136        std::fs::create_dir_all(&extract_dir)?;
1137
1138        let mut produced_paths: Vec<PathBuf> = Vec::with_capacity(extract.len());
1139        for item in extract {
1140            let source_path = Self::extract_source_path(item);
1141            let extracted_path =
1142                self.extract_binary(&data, asset, Some(source_path), &extract_dir)?;
1143            let final_path = self.cache_target_for_extract(&cache_dir, &resolved.name, item);
1144            if let Some(parent) = final_path.parent() {
1145                std::fs::create_dir_all(parent)?;
1146            }
1147            if final_path.exists() {
1148                std::fs::remove_file(&final_path)?;
1149            }
1150            std::fs::rename(&extracted_path, &final_path)?;
1151            if Self::is_executable_extract(item) {
1152                Self::ensure_executable(&final_path)?;
1153            }
1154            produced_paths.push(final_path);
1155        }
1156        if extract_dir.exists() {
1157            let _ = std::fs::remove_dir_all(&extract_dir);
1158        }
1159
1160        let primary_path = produced_paths
1161            .first()
1162            .cloned()
1163            .unwrap_or_else(|| cache_dir.join("bin").join(&resolved.name));
1164        let sha256 = compute_file_sha256(&primary_path).await?;
1165        info!(
1166            tool = %resolved.name,
1167            binary = ?primary_path,
1168            %sha256,
1169            "Fetched GitHub release"
1170        );
1171
1172        Ok(FetchedTool {
1173            name: resolved.name.clone(),
1174            binary_path: primary_path,
1175            sha256,
1176        })
1177    }
1178
1179    fn is_cached(&self, resolved: &ResolvedTool, options: &ToolOptions) -> bool {
1180        self.cache_targets_from_source(resolved, options)
1181            .into_iter()
1182            .all(|path| path.exists())
1183    }
1184}
1185
1186/// Compute SHA256 hash of a file.
1187async fn compute_file_sha256(path: &std::path::Path) -> Result<String> {
1188    let mut file = tokio::fs::File::open(path).await?;
1189    let mut hasher = Sha256::new();
1190    let mut buffer = vec![0u8; 8192];
1191
1192    loop {
1193        let n = file.read(&mut buffer).await?;
1194        if n == 0 {
1195            break;
1196        }
1197        hasher.update(&buffer[..n]);
1198    }
1199
1200    Ok(format!("{:x}", hasher.finalize()))
1201}
1202
1203#[cfg(test)]
1204#[allow(unsafe_code)]
1205mod tests {
1206    use super::*;
1207    use std::sync::{Mutex, OnceLock};
1208    use tempfile::TempDir;
1209
1210    fn with_token_env<R>(
1211        github_token: Option<&str>,
1212        gh_token: Option<&str>,
1213        test: impl FnOnce() -> R,
1214    ) -> R {
1215        static TOKEN_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1216        let _guard = TOKEN_ENV_LOCK
1217            .get_or_init(|| Mutex::new(()))
1218            .lock()
1219            .unwrap();
1220
1221        let original_github_token = std::env::var("GITHUB_TOKEN").ok();
1222        let original_gh_token = std::env::var("GH_TOKEN").ok();
1223
1224        set_token_env("GITHUB_TOKEN", github_token);
1225        set_token_env("GH_TOKEN", gh_token);
1226
1227        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test));
1228
1229        set_token_env("GITHUB_TOKEN", original_github_token.as_deref());
1230        set_token_env("GH_TOKEN", original_gh_token.as_deref());
1231
1232        match result {
1233            Ok(value) => value,
1234            Err(panic) => std::panic::resume_unwind(panic),
1235        }
1236    }
1237
1238    fn set_token_env(name: &str, value: Option<&str>) {
1239        // SAFETY: Tests serialize access to process-global env via TOKEN_ENV_LOCK.
1240        unsafe {
1241            match value {
1242                Some(value) => std::env::set_var(name, value),
1243                None => std::env::remove_var(name),
1244            }
1245        }
1246    }
1247
1248    // ==========================================================================
1249    // GitHubToolProvider construction and ToolProvider trait tests
1250    // ==========================================================================
1251
1252    #[test]
1253    fn test_provider_name() {
1254        let provider = GitHubToolProvider::new();
1255        assert_eq!(provider.name(), "github");
1256    }
1257
1258    #[test]
1259    fn test_provider_description() {
1260        let provider = GitHubToolProvider::new();
1261        assert_eq!(provider.description(), "Fetch tools from GitHub Releases");
1262    }
1263
1264    #[test]
1265    fn test_provider_default() {
1266        let provider = GitHubToolProvider::default();
1267        assert_eq!(provider.name(), "github");
1268    }
1269
1270    #[test]
1271    fn test_can_handle() {
1272        let provider = GitHubToolProvider::new();
1273
1274        let github_source = ToolSource::GitHub {
1275            repo: "org/repo".into(),
1276            tag: "v1".into(),
1277            asset: "file.zip".into(),
1278            extract: vec![],
1279        };
1280        assert!(provider.can_handle(&github_source));
1281
1282        let nix_source = ToolSource::Nix {
1283            flake: "nixpkgs".into(),
1284            package: "jq".into(),
1285            output: None,
1286        };
1287        assert!(!provider.can_handle(&nix_source));
1288    }
1289
1290    #[test]
1291    fn test_can_handle_github_with_path() {
1292        let provider = GitHubToolProvider::new();
1293
1294        let source = ToolSource::GitHub {
1295            repo: "owner/repo".into(),
1296            tag: "v1.0.0".into(),
1297            asset: "archive.tar.gz".into(),
1298            extract: vec![ToolExtract::Bin {
1299                path: "bin/tool".into(),
1300                as_name: None,
1301            }],
1302        };
1303        assert!(provider.can_handle(&source));
1304    }
1305
1306    // ==========================================================================
1307    // expand_template tests
1308    // ==========================================================================
1309
1310    #[test]
1311    fn test_expand_template() {
1312        let provider = GitHubToolProvider::new();
1313        let platform = Platform::new(Os::Darwin, Arch::Arm64);
1314
1315        assert_eq!(
1316            provider.expand_template("bun-{os}-{arch}.zip", "1.0.0", &platform),
1317            "bun-darwin-aarch64.zip"
1318        );
1319
1320        assert_eq!(
1321            provider.expand_template("v{version}", "1.0.0", &platform),
1322            "v1.0.0"
1323        );
1324    }
1325
1326    #[test]
1327    fn test_expand_template_linux_x86_64() {
1328        let provider = GitHubToolProvider::new();
1329        let platform = Platform::new(Os::Linux, Arch::X86_64);
1330
1331        assert_eq!(
1332            provider.expand_template("{os}-{arch}", "1.0.0", &platform),
1333            "linux-x86_64"
1334        );
1335    }
1336
1337    #[test]
1338    fn test_expand_template_all_placeholders() {
1339        let provider = GitHubToolProvider::new();
1340        let platform = Platform::new(Os::Darwin, Arch::X86_64);
1341
1342        assert_eq!(
1343            provider.expand_template("tool-{version}-{os}-{arch}.zip", "2.5.1", &platform),
1344            "tool-2.5.1-darwin-x86_64.zip"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_expand_template_no_placeholders() {
1350        let provider = GitHubToolProvider::new();
1351        let platform = Platform::new(Os::Linux, Arch::Arm64);
1352
1353        assert_eq!(
1354            provider.expand_template("static-name.tar.gz", "1.0.0", &platform),
1355            "static-name.tar.gz"
1356        );
1357    }
1358
1359    // ==========================================================================
1360    // tool_cache_dir tests
1361    // ==========================================================================
1362
1363    #[test]
1364    fn test_tool_cache_dir() {
1365        let provider = GitHubToolProvider::new();
1366        let temp_dir = TempDir::new().unwrap();
1367        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
1368
1369        let cache_dir = provider.tool_cache_dir(&options, "mytool", "1.2.3");
1370
1371        assert!(cache_dir.ends_with("github/mytool/1.2.3"));
1372        assert!(cache_dir.starts_with(temp_dir.path()));
1373    }
1374
1375    #[test]
1376    fn test_tool_cache_dir_different_versions() {
1377        let provider = GitHubToolProvider::new();
1378        let temp_dir = TempDir::new().unwrap();
1379        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
1380
1381        let cache_v1 = provider.tool_cache_dir(&options, "tool", "1.0.0");
1382        let cache_v2 = provider.tool_cache_dir(&options, "tool", "2.0.0");
1383
1384        assert_ne!(cache_v1, cache_v2);
1385        assert!(cache_v1.ends_with("1.0.0"));
1386        assert!(cache_v2.ends_with("2.0.0"));
1387    }
1388
1389    // ==========================================================================
1390    // library path routing tests
1391    // ==========================================================================
1392
1393    #[test]
1394    fn test_path_looks_like_library() {
1395        assert!(GitHubToolProvider::path_looks_like_library(
1396            "lib/libfdb_c.dylib"
1397        ));
1398        assert!(GitHubToolProvider::path_looks_like_library(
1399            "lib/libssl.so.3"
1400        ));
1401        assert!(GitHubToolProvider::path_looks_like_library(
1402            "bin/sqlite3.dll"
1403        ));
1404        assert!(!GitHubToolProvider::path_looks_like_library("bin/fdbcli"));
1405    }
1406
1407    #[test]
1408    fn test_cache_targets_from_source_uses_lib_for_library_extract() {
1409        let provider = GitHubToolProvider::new();
1410        let temp_dir = TempDir::new().unwrap();
1411        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
1412
1413        let resolved = ResolvedTool {
1414            name: "foundationdb".to_string(),
1415            version: "7.3.63".to_string(),
1416            platform: Platform::new(Os::Darwin, Arch::Arm64),
1417            source: ToolSource::GitHub {
1418                repo: "apple/foundationdb".to_string(),
1419                tag: "7.3.63".to_string(),
1420                asset: "FoundationDB-7.3.63_arm64.pkg".to_string(),
1421                extract: vec![ToolExtract::Lib {
1422                    path: "libfdb_c.dylib".to_string(),
1423                    env: None,
1424                }],
1425            },
1426        };
1427
1428        let target = provider
1429            .cache_targets_from_source(&resolved, &options)
1430            .into_iter()
1431            .next()
1432            .unwrap();
1433        assert!(target.ends_with("github/foundationdb/7.3.63/lib/libfdb_c.dylib"));
1434    }
1435
1436    #[test]
1437    fn test_cache_targets_from_source_uses_bin_for_default_extract() {
1438        let provider = GitHubToolProvider::new();
1439        let temp_dir = TempDir::new().unwrap();
1440        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
1441
1442        let resolved = ResolvedTool {
1443            name: "foundationdb".to_string(),
1444            version: "7.3.63".to_string(),
1445            platform: Platform::new(Os::Darwin, Arch::Arm64),
1446            source: ToolSource::GitHub {
1447                repo: "apple/foundationdb".to_string(),
1448                tag: "7.3.63".to_string(),
1449                asset: "FoundationDB-7.3.63_arm64.pkg".to_string(),
1450                extract: vec![],
1451            },
1452        };
1453        let target = provider
1454            .cache_targets_from_source(&resolved, &options)
1455            .into_iter()
1456            .next()
1457            .unwrap();
1458        assert!(target.ends_with("github/foundationdb/7.3.63/bin/foundationdb"));
1459    }
1460
1461    // ==========================================================================
1462    // get_effective_token tests
1463    // ==========================================================================
1464
1465    #[test]
1466    fn test_get_effective_token_runtime_only() {
1467        with_token_env(None, None, || {
1468            let token = GitHubToolProvider::get_effective_token(Some("runtime-token"));
1469            assert_eq!(token, Some("runtime-token".to_string()));
1470        });
1471    }
1472
1473    #[test]
1474    fn test_get_effective_token_none() {
1475        with_token_env(None, None, || {
1476            let token = GitHubToolProvider::get_effective_token(None);
1477            assert!(token.is_none());
1478        });
1479    }
1480
1481    #[test]
1482    fn test_get_effective_token_github_token_priority() {
1483        with_token_env(Some("github-token"), Some("gh-token"), || {
1484            let token = GitHubToolProvider::get_effective_token(Some("runtime-token"));
1485            assert_eq!(token, Some("github-token".to_string()));
1486        });
1487    }
1488
1489    #[test]
1490    fn test_get_effective_token_gh_token_fallback() {
1491        with_token_env(None, Some("gh-token"), || {
1492            let token = GitHubToolProvider::get_effective_token(Some("runtime-token"));
1493            assert_eq!(token, Some("gh-token".to_string()));
1494        });
1495    }
1496
1497    // ==========================================================================
1498    // RateLimitInfo tests
1499    // ==========================================================================
1500
1501    #[test]
1502    fn test_rate_limit_info_from_headers() {
1503        use reqwest::header::{HeaderMap, HeaderValue};
1504
1505        let mut headers = HeaderMap::new();
1506        headers.insert("x-ratelimit-limit", HeaderValue::from_static("60"));
1507        headers.insert("x-ratelimit-remaining", HeaderValue::from_static("0"));
1508        headers.insert("x-ratelimit-reset", HeaderValue::from_static("1735689600"));
1509
1510        let info = RateLimitInfo::from_headers(&headers);
1511        assert_eq!(info.limit, Some(60));
1512        assert_eq!(info.remaining, Some(0));
1513        assert_eq!(info.reset, Some(1_735_689_600));
1514        assert!(info.is_exceeded());
1515    }
1516
1517    #[test]
1518    fn test_rate_limit_info_not_exceeded() {
1519        use reqwest::header::{HeaderMap, HeaderValue};
1520
1521        let mut headers = HeaderMap::new();
1522        headers.insert("x-ratelimit-limit", HeaderValue::from_static("5000"));
1523        headers.insert("x-ratelimit-remaining", HeaderValue::from_static("4999"));
1524
1525        let info = RateLimitInfo::from_headers(&headers);
1526        assert!(!info.is_exceeded());
1527    }
1528
1529    #[test]
1530    fn test_rate_limit_info_format_status() {
1531        let info = RateLimitInfo {
1532            limit: Some(60),
1533            remaining: Some(0),
1534            reset: None,
1535        };
1536        assert_eq!(
1537            info.format_status(),
1538            Some("0/60 requests remaining".to_string())
1539        );
1540
1541        let info_partial = RateLimitInfo {
1542            limit: Some(60),
1543            remaining: None,
1544            reset: None,
1545        };
1546        assert_eq!(info_partial.format_status(), None);
1547    }
1548
1549    #[test]
1550    fn test_rate_limit_info_empty_headers() {
1551        let headers = reqwest::header::HeaderMap::new();
1552        let info = RateLimitInfo::from_headers(&headers);
1553
1554        assert_eq!(info.limit, None);
1555        assert_eq!(info.remaining, None);
1556        assert_eq!(info.reset, None);
1557        assert!(!info.is_exceeded());
1558    }
1559
1560    #[test]
1561    fn test_rate_limit_info_default() {
1562        let info = RateLimitInfo::default();
1563        assert_eq!(info.limit, None);
1564        assert_eq!(info.remaining, None);
1565        assert_eq!(info.reset, None);
1566        assert!(!info.is_exceeded());
1567    }
1568
1569    #[test]
1570    fn test_rate_limit_info_format_status_missing_remaining() {
1571        let info = RateLimitInfo {
1572            limit: Some(60),
1573            remaining: None,
1574            reset: None,
1575        };
1576        assert!(info.format_status().is_none());
1577    }
1578
1579    #[test]
1580    fn test_rate_limit_info_format_status_missing_limit() {
1581        let info = RateLimitInfo {
1582            limit: None,
1583            remaining: Some(50),
1584            reset: None,
1585        };
1586        assert!(info.format_status().is_none());
1587    }
1588
1589    #[test]
1590    fn test_rate_limit_info_format_reset_duration_none() {
1591        let info = RateLimitInfo {
1592            limit: None,
1593            remaining: None,
1594            reset: None,
1595        };
1596        assert!(info.format_reset_duration().is_none());
1597    }
1598
1599    #[test]
1600    fn test_rate_limit_info_format_reset_duration_past() {
1601        // Use a timestamp in the past
1602        let info = RateLimitInfo {
1603            limit: None,
1604            remaining: None,
1605            reset: Some(0), // epoch
1606        };
1607        // Should return "now" for past timestamps
1608        assert_eq!(info.format_reset_duration(), Some("now".to_string()));
1609    }
1610
1611    #[test]
1612    fn test_rate_limit_info_invalid_header_values() {
1613        use reqwest::header::{HeaderMap, HeaderValue};
1614
1615        let mut headers = HeaderMap::new();
1616        headers.insert(
1617            "x-ratelimit-limit",
1618            HeaderValue::from_static("not-a-number"),
1619        );
1620        headers.insert("x-ratelimit-remaining", HeaderValue::from_static("invalid"));
1621
1622        let info = RateLimitInfo::from_headers(&headers);
1623        assert_eq!(info.limit, None);
1624        assert_eq!(info.remaining, None);
1625    }
1626
1627    // ==========================================================================
1628    // build_api_error tests
1629    // ==========================================================================
1630
1631    #[test]
1632    fn test_build_api_error_rate_limit_exceeded_unauthenticated() {
1633        let rate_limit = RateLimitInfo {
1634            limit: Some(60),
1635            remaining: Some(0),
1636            reset: Some(1_735_689_600),
1637        };
1638
1639        let error = GitHubToolProvider::build_api_error(
1640            reqwest::StatusCode::FORBIDDEN,
1641            &rate_limit,
1642            false,
1643            "release owner/repo v1.0.0",
1644        );
1645
1646        let msg = error.to_string();
1647        assert!(msg.contains("rate limit exceeded"));
1648    }
1649
1650    #[test]
1651    fn test_build_api_error_rate_limit_exceeded_authenticated() {
1652        let rate_limit = RateLimitInfo {
1653            limit: Some(5000),
1654            remaining: Some(0),
1655            reset: None,
1656        };
1657
1658        let error = GitHubToolProvider::build_api_error(
1659            reqwest::StatusCode::FORBIDDEN,
1660            &rate_limit,
1661            true,
1662            "release owner/repo v1.0.0",
1663        );
1664
1665        let msg = error.to_string();
1666        assert!(msg.contains("rate limit exceeded"));
1667    }
1668
1669    #[test]
1670    fn test_build_api_error_forbidden_not_rate_limit() {
1671        let rate_limit = RateLimitInfo {
1672            limit: Some(60),
1673            remaining: Some(30),
1674            reset: None,
1675        };
1676
1677        let error = GitHubToolProvider::build_api_error(
1678            reqwest::StatusCode::FORBIDDEN,
1679            &rate_limit,
1680            false,
1681            "release owner/repo v1.0.0",
1682        );
1683
1684        let msg = error.to_string();
1685        assert!(msg.contains("Access denied"));
1686    }
1687
1688    #[test]
1689    fn test_build_api_error_not_found() {
1690        let rate_limit = RateLimitInfo::default();
1691
1692        let error = GitHubToolProvider::build_api_error(
1693            reqwest::StatusCode::NOT_FOUND,
1694            &rate_limit,
1695            false,
1696            "release owner/repo v999.0.0",
1697        );
1698
1699        let msg = error.to_string();
1700        assert!(msg.contains("not found"));
1701        assert!(msg.contains("404"));
1702    }
1703
1704    #[test]
1705    fn test_build_api_error_unauthorized() {
1706        let rate_limit = RateLimitInfo::default();
1707
1708        let error = GitHubToolProvider::build_api_error(
1709            reqwest::StatusCode::UNAUTHORIZED,
1710            &rate_limit,
1711            true,
1712            "release owner/repo v1.0.0",
1713        );
1714
1715        let msg = error.to_string();
1716        assert!(msg.contains("Authentication failed"));
1717        assert!(msg.contains("401"));
1718    }
1719
1720    #[test]
1721    fn test_build_api_error_server_error() {
1722        let rate_limit = RateLimitInfo::default();
1723
1724        let error = GitHubToolProvider::build_api_error(
1725            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1726            &rate_limit,
1727            false,
1728            "asset download",
1729        );
1730
1731        let msg = error.to_string();
1732        assert!(msg.contains("HTTP 500"));
1733    }
1734
1735    // ==========================================================================
1736    // is_cached tests
1737    // ==========================================================================
1738
1739    #[test]
1740    fn test_is_cached_not_cached() {
1741        let provider = GitHubToolProvider::new();
1742        let temp_dir = TempDir::new().unwrap();
1743        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
1744
1745        let resolved = ResolvedTool {
1746            name: "mytool".to_string(),
1747            version: "1.0.0".to_string(),
1748            platform: Platform::new(Os::Darwin, Arch::Arm64),
1749            source: ToolSource::GitHub {
1750                repo: "owner/repo".to_string(),
1751                tag: "v1.0.0".to_string(),
1752                asset: "mytool.tar.gz".to_string(),
1753                extract: vec![],
1754            },
1755        };
1756
1757        assert!(!provider.is_cached(&resolved, &options));
1758    }
1759
1760    #[test]
1761    fn test_is_cached_cached() {
1762        let provider = GitHubToolProvider::new();
1763        let temp_dir = TempDir::new().unwrap();
1764        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
1765
1766        let resolved = ResolvedTool {
1767            name: "mytool".to_string(),
1768            version: "1.0.0".to_string(),
1769            platform: Platform::new(Os::Darwin, Arch::Arm64),
1770            source: ToolSource::GitHub {
1771                repo: "owner/repo".to_string(),
1772                tag: "v1.0.0".to_string(),
1773                asset: "mytool.tar.gz".to_string(),
1774                extract: vec![],
1775            },
1776        };
1777
1778        // Create the cached file
1779        let cache_dir = provider.tool_cache_dir(&options, "mytool", "1.0.0");
1780        let bin_dir = cache_dir.join("bin");
1781        std::fs::create_dir_all(&bin_dir).unwrap();
1782        std::fs::write(bin_dir.join("mytool"), b"binary").unwrap();
1783
1784        assert!(provider.is_cached(&resolved, &options));
1785    }
1786
1787    #[test]
1788    fn test_is_cached_library_path_uses_lib_directory() {
1789        let provider = GitHubToolProvider::new();
1790        let temp_dir = TempDir::new().unwrap();
1791        let options = ToolOptions::new().with_cache_dir(temp_dir.path().to_path_buf());
1792
1793        let resolved = ResolvedTool {
1794            name: "foundationdb".to_string(),
1795            version: "7.3.63".to_string(),
1796            platform: Platform::new(Os::Darwin, Arch::Arm64),
1797            source: ToolSource::GitHub {
1798                repo: "apple/foundationdb".to_string(),
1799                tag: "7.3.63".to_string(),
1800                asset: "FoundationDB-7.3.63_arm64.pkg".to_string(),
1801                extract: vec![ToolExtract::Lib {
1802                    path: "libfdb_c.dylib".to_string(),
1803                    env: None,
1804                }],
1805            },
1806        };
1807
1808        let cache_dir = provider.tool_cache_dir(&options, "foundationdb", "7.3.63");
1809        let lib_dir = cache_dir.join("lib");
1810        std::fs::create_dir_all(&lib_dir).unwrap();
1811        std::fs::write(lib_dir.join("libfdb_c.dylib"), b"library").unwrap();
1812
1813        assert!(provider.is_cached(&resolved, &options));
1814    }
1815
1816    // ==========================================================================
1817    // Release and Asset struct tests
1818    // ==========================================================================
1819
1820    #[test]
1821    fn test_release_deserialization() {
1822        let json = r#"{
1823            "tag_name": "v1.0.0",
1824            "assets": [
1825                {"name": "tool-linux.tar.gz", "browser_download_url": "https://example.com/linux.tar.gz"},
1826                {"name": "tool-darwin.tar.gz", "browser_download_url": "https://example.com/darwin.tar.gz"}
1827            ]
1828        }"#;
1829
1830        let release: Release = serde_json::from_str(json).unwrap();
1831        assert_eq!(release.tag_name, "v1.0.0");
1832        assert_eq!(release.assets.len(), 2);
1833        assert_eq!(release.assets[0].name, "tool-linux.tar.gz");
1834        assert_eq!(
1835            release.assets[0].browser_download_url,
1836            "https://example.com/linux.tar.gz"
1837        );
1838    }
1839
1840    #[test]
1841    fn test_release_deserialization_empty_assets() {
1842        let json = r#"{"tag_name": "v0.1.0", "assets": []}"#;
1843        let release: Release = serde_json::from_str(json).unwrap();
1844        assert!(release.assets.is_empty());
1845    }
1846}