1use 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#[derive(Debug, Default)]
26struct RateLimitInfo {
27 limit: Option<u32>,
29 remaining: Option<u32>,
31 reset: Option<u64>,
33}
34
35impl RateLimitInfo {
36 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 fn is_exceeded(&self) -> bool {
56 self.remaining == Some(0)
57 }
58
59 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 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#[derive(Debug, Deserialize)]
97struct Release {
98 #[allow(dead_code)] tag_name: String,
100 assets: Vec<Asset>,
101}
102
103#[derive(Debug, Deserialize)]
105struct Asset {
106 name: String,
107 browser_download_url: String,
108}
109
110pub struct GitHubToolProvider {
115 client: Client,
116}
117
118impl Default for GitHubToolProvider {
119 fn default() -> Self {
120 Self::new()
121 }
122}
123
124impl GitHubToolProvider {
125 #[must_use]
133 pub fn new() -> Self {
134 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 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 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 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 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 fn build_api_error(
215 status: reqwest::StatusCode,
216 rate_limit: &RateLimitInfo,
217 is_authenticated: bool,
218 resource: &str,
219 ) -> cuenv_core::Error {
220 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 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 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 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 cuenv_core::Error::tool_resolution(format!("Failed to fetch {}: HTTP {}", resource, status))
273 }
274
275 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 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 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 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 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 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 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 if temp_dir.exists() {
410 std::fs::remove_dir_all(&temp_dir)?;
411 }
412 std::fs::create_dir_all(&temp_dir)?;
413
414 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 if let Err(e) = extract_result {
450 let _ = std::fs::remove_dir_all(&temp_dir);
451 return Err(e);
452 }
453
454 if dest.exists() {
456 std::fs::remove_dir_all(dest)?;
457 }
458 std::fs::rename(&temp_dir, dest)?;
459
460 self.find_main_binary(dest)
462 }
463
464 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 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 archive.unpack(dest).map_err(|e| {
522 cuenv_core::Error::tool_resolution(format!("Failed to extract tar: {}", e))
523 })?;
524
525 self.find_main_binary(dest)
527 }
528
529 fn find_main_binary(&self, dir: &std::path::Path) -> Result<PathBuf> {
531 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 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 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 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 let release = self.fetch_release(repo, &tag, token).await?;
626
627 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 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 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 std::fs::create_dir_all(&bin_dir)?;
704
705 let extracted = self.extract_binary(&data, asset, path.as_deref(), &cache_dir)?;
707
708 let final_path = bin_dir.join(&resolved.name);
710 if extracted != final_path {
711 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
741async 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 #[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 #[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 #[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 #[test]
907 fn test_get_effective_token_runtime_only() {
908 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 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 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 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 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 unsafe {
962 std::env::remove_var("GH_TOKEN");
963 }
964 }
965
966 #[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 let info = RateLimitInfo {
1072 limit: None,
1073 remaining: None,
1074 reset: Some(0), };
1076 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 #[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 #[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 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 #[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}