1use 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#[derive(Debug, Default)]
33struct RateLimitInfo {
34 limit: Option<u32>,
36 remaining: Option<u32>,
38 reset: Option<u64>,
40}
41
42impl RateLimitInfo {
43 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 fn is_exceeded(&self) -> bool {
63 self.remaining == Some(0)
64 }
65
66 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 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#[derive(Debug, Deserialize)]
104struct Release {
105 #[allow(dead_code)] tag_name: String,
107 assets: Vec<Asset>,
108}
109
110#[derive(Debug, Deserialize)]
112struct Asset {
113 name: String,
114 browser_download_url: String,
115}
116
117pub 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 #[must_use]
160 pub fn new() -> Self {
161 Self {
162 client: Self::build_client(),
163 }
164 }
165
166 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 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 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 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 fn build_api_error(
235 status: reqwest::StatusCode,
236 rate_limit: &RateLimitInfo,
237 is_authenticated: bool,
238 resource: &str,
239 ) -> cuenv_core::Error {
240 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 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 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 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 cuenv_core::Error::tool_resolution(format!("Failed to fetch {}: HTTP {}", resource, status))
293 }
294
295 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 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 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 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 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 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 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 if temp_dir.exists() {
433 std::fs::remove_dir_all(&temp_dir)?;
434 }
435 std::fs::create_dir_all(&temp_dir)?;
436
437 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 if let Err(e) = extract_result {
473 let _ = std::fs::remove_dir_all(&temp_dir);
474 return Err(e);
475 }
476
477 if dest.exists() {
479 std::fs::remove_dir_all(dest)?;
480 }
481 std::fs::rename(&temp_dir, dest)?;
482
483 self.find_main_binary(dest)
485 }
486
487 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 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 archive.unpack(dest).map_err(|e| {
545 cuenv_core::Error::tool_resolution(format!("Failed to extract tar: {}", e))
546 })?;
547
548 self.find_main_binary(dest)
550 }
551
552 #[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 #[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 #[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 #[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 #[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(¤t)? {
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 #[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(¤t)? {
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 #[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 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 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 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 fn find_main_binary(&self, dir: &std::path::Path) -> Result<PathBuf> {
888 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 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 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 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 let release = self.fetch_release(repo, &tag, token).await?;
1014
1015 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 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 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 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 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
1186async 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 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 #[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 #[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 #[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 #[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 #[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 #[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 let info = RateLimitInfo {
1603 limit: None,
1604 remaining: None,
1605 reset: Some(0), };
1607 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 #[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 #[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 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 #[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}