1use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11use crate::Result;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct Platform {
16 pub os: Os,
17 pub arch: Arch,
18}
19
20impl Platform {
21 #[must_use]
23 pub fn new(os: Os, arch: Arch) -> Self {
24 Self { os, arch }
25 }
26
27 #[must_use]
29 pub fn current() -> Self {
30 Self {
31 os: Os::current(),
32 arch: Arch::current(),
33 }
34 }
35
36 pub fn parse(s: &str) -> Option<Self> {
38 let parts: Vec<&str> = s.split('-').collect();
39 if parts.len() != 2 {
40 return None;
41 }
42 Some(Self {
43 os: Os::parse(parts[0])?,
44 arch: Arch::parse(parts[1])?,
45 })
46 }
47}
48
49impl std::fmt::Display for Platform {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(f, "{}-{}", self.os, self.arch)
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Os {
59 Darwin,
60 Linux,
61}
62
63impl Os {
64 #[must_use]
66 pub fn current() -> Self {
67 #[cfg(target_os = "macos")]
68 return Self::Darwin;
69 #[cfg(target_os = "linux")]
70 return Self::Linux;
71 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
72 compile_error!("Unsupported OS");
73 }
74
75 #[must_use]
77 pub fn parse(s: &str) -> Option<Self> {
78 match s.to_lowercase().as_str() {
79 "darwin" | "macos" => Some(Self::Darwin),
80 "linux" => Some(Self::Linux),
81 _ => None,
82 }
83 }
84}
85
86impl std::fmt::Display for Os {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 match self {
89 Self::Darwin => write!(f, "darwin"),
90 Self::Linux => write!(f, "linux"),
91 }
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "lowercase")]
98pub enum Arch {
99 Arm64,
100 X86_64,
101}
102
103impl Arch {
104 #[must_use]
106 pub fn current() -> Self {
107 #[cfg(target_arch = "aarch64")]
108 return Self::Arm64;
109 #[cfg(target_arch = "x86_64")]
110 return Self::X86_64;
111 #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
112 compile_error!("Unsupported architecture");
113 }
114
115 #[must_use]
117 pub fn parse(s: &str) -> Option<Self> {
118 match s.to_lowercase().as_str() {
119 "arm64" | "aarch64" => Some(Self::Arm64),
120 "x86_64" | "amd64" | "x64" => Some(Self::X86_64),
121 _ => None,
122 }
123 }
124}
125
126impl std::fmt::Display for Arch {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 match self {
129 Self::Arm64 => write!(f, "arm64"),
130 Self::X86_64 => write!(f, "x86_64"),
131 }
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(tag = "type", rename_all = "lowercase")]
140pub enum ToolSource {
141 Oci { image: String, path: String },
143 GitHub {
145 repo: String,
146 tag: String,
147 asset: String,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 path: Option<String>,
150 },
151 Nix {
153 flake: String,
154 package: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 output: Option<String>,
157 },
158 Rustup {
160 toolchain: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 profile: Option<String>,
165 #[serde(skip_serializing_if = "Vec::is_empty", default)]
167 components: Vec<String>,
168 #[serde(skip_serializing_if = "Vec::is_empty", default)]
170 targets: Vec<String>,
171 },
172}
173
174impl ToolSource {
175 #[must_use]
177 pub fn provider_type(&self) -> &'static str {
178 match self {
179 Self::Oci { .. } => "oci",
180 Self::GitHub { .. } => "github",
181 Self::Nix { .. } => "nix",
182 Self::Rustup { .. } => "rustup",
183 }
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct ResolvedTool {
193 pub name: String,
195 pub version: String,
197 pub platform: Platform,
199 pub source: ToolSource,
201}
202
203#[derive(Debug)]
205pub struct FetchedTool {
206 pub name: String,
208 pub binary_path: PathBuf,
210 pub sha256: String,
212}
213
214#[derive(Debug, Clone, Default)]
216pub struct ToolOptions {
217 pub cache_dir: Option<PathBuf>,
219 pub force_refetch: bool,
221}
222
223impl ToolOptions {
224 #[must_use]
226 pub fn new() -> Self {
227 Self::default()
228 }
229
230 #[must_use]
232 pub fn with_cache_dir(mut self, path: PathBuf) -> Self {
233 self.cache_dir = Some(path);
234 self
235 }
236
237 #[must_use]
239 pub fn with_force_refetch(mut self, force: bool) -> Self {
240 self.force_refetch = force;
241 self
242 }
243
244 #[must_use]
246 pub fn cache_dir(&self) -> PathBuf {
247 self.cache_dir.clone().unwrap_or_else(default_cache_dir)
248 }
249}
250
251#[must_use]
253pub fn default_cache_dir() -> PathBuf {
254 dirs::cache_dir()
255 .unwrap_or_else(|| PathBuf::from(".cache"))
256 .join("cuenv")
257 .join("tools")
258}
259
260#[async_trait]
279pub trait ToolProvider: Send + Sync {
280 fn name(&self) -> &'static str;
284
285 fn description(&self) -> &'static str;
287
288 fn can_handle(&self, source: &ToolSource) -> bool;
290
291 async fn resolve(
307 &self,
308 tool_name: &str,
309 version: &str,
310 platform: &Platform,
311 config: &serde_json::Value,
312 ) -> Result<ResolvedTool>;
313
314 #[allow(clippy::too_many_arguments)]
332 async fn resolve_with_token(
333 &self,
334 tool_name: &str,
335 version: &str,
336 platform: &Platform,
337 config: &serde_json::Value,
338 _token: Option<&str>,
339 ) -> Result<ResolvedTool> {
340 self.resolve(tool_name, version, platform, config).await
341 }
342
343 async fn fetch(&self, resolved: &ResolvedTool, options: &ToolOptions) -> Result<FetchedTool>;
358
359 fn is_cached(&self, resolved: &ResolvedTool, options: &ToolOptions) -> bool;
363
364 async fn check_prerequisites(&self) -> Result<()> {
377 Ok(())
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn test_platform_parse() {
387 let p = Platform::parse("darwin-arm64").unwrap();
388 assert_eq!(p.os, Os::Darwin);
389 assert_eq!(p.arch, Arch::Arm64);
390
391 let p = Platform::parse("linux-x86_64").unwrap();
392 assert_eq!(p.os, Os::Linux);
393 assert_eq!(p.arch, Arch::X86_64);
394
395 assert!(Platform::parse("invalid").is_none());
396 }
397
398 #[test]
399 fn test_platform_parse_edge_cases() {
400 assert!(Platform::parse("darwin").is_none());
402 assert!(Platform::parse("darwin-arm64-extra").is_none());
404 assert!(Platform::parse("").is_none());
406 assert!(Platform::parse("windows-arm64").is_none());
408 assert!(Platform::parse("darwin-mips").is_none());
410 }
411
412 #[test]
413 fn test_platform_display() {
414 let p = Platform::new(Os::Darwin, Arch::Arm64);
415 assert_eq!(p.to_string(), "darwin-arm64");
416 }
417
418 #[test]
419 fn test_platform_display_all_combinations() {
420 assert_eq!(
421 Platform::new(Os::Darwin, Arch::Arm64).to_string(),
422 "darwin-arm64"
423 );
424 assert_eq!(
425 Platform::new(Os::Darwin, Arch::X86_64).to_string(),
426 "darwin-x86_64"
427 );
428 assert_eq!(
429 Platform::new(Os::Linux, Arch::Arm64).to_string(),
430 "linux-arm64"
431 );
432 assert_eq!(
433 Platform::new(Os::Linux, Arch::X86_64).to_string(),
434 "linux-x86_64"
435 );
436 }
437
438 #[test]
439 fn test_platform_current() {
440 let p = Platform::current();
441 assert!(matches!(p.os, Os::Darwin | Os::Linux));
443 assert!(matches!(p.arch, Arch::Arm64 | Arch::X86_64));
444 }
445
446 #[test]
447 fn test_os_parse() {
448 assert_eq!(Os::parse("darwin"), Some(Os::Darwin));
449 assert_eq!(Os::parse("macos"), Some(Os::Darwin));
450 assert_eq!(Os::parse("linux"), Some(Os::Linux));
451 assert_eq!(Os::parse("windows"), None);
452 }
453
454 #[test]
455 fn test_os_parse_case_insensitive() {
456 assert_eq!(Os::parse("DARWIN"), Some(Os::Darwin));
457 assert_eq!(Os::parse("Darwin"), Some(Os::Darwin));
458 assert_eq!(Os::parse("LINUX"), Some(Os::Linux));
459 assert_eq!(Os::parse("Linux"), Some(Os::Linux));
460 assert_eq!(Os::parse("MACOS"), Some(Os::Darwin));
461 assert_eq!(Os::parse("MacOS"), Some(Os::Darwin));
462 }
463
464 #[test]
465 fn test_os_display() {
466 assert_eq!(Os::Darwin.to_string(), "darwin");
467 assert_eq!(Os::Linux.to_string(), "linux");
468 }
469
470 #[test]
471 fn test_os_current() {
472 let os = Os::current();
473 assert!(matches!(os, Os::Darwin | Os::Linux));
475 }
476
477 #[test]
478 fn test_arch_parse() {
479 assert_eq!(Arch::parse("arm64"), Some(Arch::Arm64));
480 assert_eq!(Arch::parse("aarch64"), Some(Arch::Arm64));
481 assert_eq!(Arch::parse("x86_64"), Some(Arch::X86_64));
482 assert_eq!(Arch::parse("amd64"), Some(Arch::X86_64));
483 }
484
485 #[test]
486 fn test_arch_parse_case_insensitive() {
487 assert_eq!(Arch::parse("ARM64"), Some(Arch::Arm64));
488 assert_eq!(Arch::parse("Arm64"), Some(Arch::Arm64));
489 assert_eq!(Arch::parse("AARCH64"), Some(Arch::Arm64));
490 assert_eq!(Arch::parse("X86_64"), Some(Arch::X86_64));
491 assert_eq!(Arch::parse("AMD64"), Some(Arch::X86_64));
492 }
493
494 #[test]
495 fn test_arch_parse_x64_alias() {
496 assert_eq!(Arch::parse("x64"), Some(Arch::X86_64));
497 assert_eq!(Arch::parse("X64"), Some(Arch::X86_64));
498 }
499
500 #[test]
501 fn test_arch_parse_invalid() {
502 assert!(Arch::parse("mips").is_none());
503 assert!(Arch::parse("riscv").is_none());
504 assert!(Arch::parse("").is_none());
505 }
506
507 #[test]
508 fn test_arch_display() {
509 assert_eq!(Arch::Arm64.to_string(), "arm64");
510 assert_eq!(Arch::X86_64.to_string(), "x86_64");
511 }
512
513 #[test]
514 fn test_arch_current() {
515 let arch = Arch::current();
516 assert!(matches!(arch, Arch::Arm64 | Arch::X86_64));
518 }
519
520 #[test]
521 fn test_tool_source_provider_type() {
522 let s = ToolSource::GitHub {
523 repo: "jqlang/jq".into(),
524 tag: "jq-1.7.1".into(),
525 asset: "jq-macos-arm64".into(),
526 path: None,
527 };
528 assert_eq!(s.provider_type(), "github");
529
530 let s = ToolSource::Nix {
531 flake: "nixpkgs".into(),
532 package: "jq".into(),
533 output: None,
534 };
535 assert_eq!(s.provider_type(), "nix");
536
537 let s = ToolSource::Rustup {
538 toolchain: "1.83.0".into(),
539 profile: Some("default".into()),
540 components: vec!["clippy".into(), "rustfmt".into()],
541 targets: vec!["x86_64-unknown-linux-gnu".into()],
542 };
543 assert_eq!(s.provider_type(), "rustup");
544 }
545
546 #[test]
547 fn test_tool_source_oci_provider_type() {
548 let s = ToolSource::Oci {
549 image: "docker.io/library/alpine:latest".into(),
550 path: "/usr/bin/jq".into(),
551 };
552 assert_eq!(s.provider_type(), "oci");
553 }
554
555 #[test]
556 fn test_tool_source_serialization() {
557 let source = ToolSource::GitHub {
558 repo: "jqlang/jq".into(),
559 tag: "jq-1.7.1".into(),
560 asset: "jq-macos-arm64".into(),
561 path: Some("jq-macos-arm64/jq".into()),
562 };
563 let json = serde_json::to_string(&source).unwrap();
564 assert!(json.contains("\"type\":\"github\""));
565 assert!(json.contains("\"repo\":\"jqlang/jq\""));
566 assert!(json.contains("\"path\":\"jq-macos-arm64/jq\""));
567 }
568
569 #[test]
570 fn test_tool_source_deserialization() {
571 let json =
572 r#"{"type":"github","repo":"jqlang/jq","tag":"jq-1.7.1","asset":"jq-macos-arm64"}"#;
573 let source: ToolSource = serde_json::from_str(json).unwrap();
574 match source {
575 ToolSource::GitHub {
576 repo, tag, asset, ..
577 } => {
578 assert_eq!(repo, "jqlang/jq");
579 assert_eq!(tag, "jq-1.7.1");
580 assert_eq!(asset, "jq-macos-arm64");
581 }
582 _ => panic!("Expected GitHub source"),
583 }
584 }
585
586 #[test]
587 fn test_tool_source_nix_serialization() {
588 let source = ToolSource::Nix {
589 flake: "nixpkgs".into(),
590 package: "jq".into(),
591 output: Some("bin".into()),
592 };
593 let json = serde_json::to_string(&source).unwrap();
594 assert!(json.contains("\"type\":\"nix\""));
595 assert!(json.contains("\"output\":\"bin\""));
596 }
597
598 #[test]
599 fn test_tool_source_rustup_serialization() {
600 let source = ToolSource::Rustup {
601 toolchain: "stable".into(),
602 profile: None,
603 components: vec![],
604 targets: vec![],
605 };
606 let json = serde_json::to_string(&source).unwrap();
607 assert!(json.contains("\"type\":\"rustup\""));
608 assert!(!json.contains("components"));
610 assert!(!json.contains("targets"));
611 }
612
613 #[test]
614 fn test_resolved_tool_serialization() {
615 let tool = ResolvedTool {
616 name: "jq".into(),
617 version: "1.7.1".into(),
618 platform: Platform::new(Os::Darwin, Arch::Arm64),
619 source: ToolSource::GitHub {
620 repo: "jqlang/jq".into(),
621 tag: "jq-1.7.1".into(),
622 asset: "jq-macos-arm64".into(),
623 path: None,
624 },
625 };
626 let json = serde_json::to_string(&tool).unwrap();
627 assert!(json.contains("\"name\":\"jq\""));
628 assert!(json.contains("\"version\":\"1.7.1\""));
629 }
630
631 #[test]
632 fn test_tool_options_default() {
633 let opts = ToolOptions::default();
634 assert!(opts.cache_dir.is_none());
635 assert!(!opts.force_refetch);
636 }
637
638 #[test]
639 fn test_tool_options_new() {
640 let opts = ToolOptions::new();
641 assert!(opts.cache_dir.is_none());
642 assert!(!opts.force_refetch);
643 }
644
645 #[test]
646 fn test_tool_options_builder() {
647 let opts = ToolOptions::new()
648 .with_cache_dir(PathBuf::from("/custom/cache"))
649 .with_force_refetch(true);
650
651 assert_eq!(opts.cache_dir, Some(PathBuf::from("/custom/cache")));
652 assert!(opts.force_refetch);
653 }
654
655 #[test]
656 fn test_tool_options_cache_dir_default() {
657 let opts = ToolOptions::new();
658 let cache_dir = opts.cache_dir();
659 assert!(cache_dir.ends_with("cuenv/tools"));
661 }
662
663 #[test]
664 fn test_tool_options_cache_dir_custom() {
665 let opts = ToolOptions::new().with_cache_dir(PathBuf::from("/my/cache"));
666 assert_eq!(opts.cache_dir(), PathBuf::from("/my/cache"));
667 }
668
669 #[test]
670 fn test_default_cache_dir() {
671 let cache_dir = default_cache_dir();
672 assert!(cache_dir.ends_with("cuenv/tools"));
674 }
675
676 #[test]
677 fn test_platform_equality() {
678 let p1 = Platform::new(Os::Darwin, Arch::Arm64);
679 let p2 = Platform::new(Os::Darwin, Arch::Arm64);
680 let p3 = Platform::new(Os::Linux, Arch::Arm64);
681
682 assert_eq!(p1, p2);
683 assert_ne!(p1, p3);
684 }
685
686 #[test]
687 fn test_platform_hash() {
688 use std::collections::HashSet;
689
690 let mut set = HashSet::new();
691 set.insert(Platform::new(Os::Darwin, Arch::Arm64));
692 set.insert(Platform::new(Os::Darwin, Arch::Arm64)); assert_eq!(set.len(), 1);
695
696 set.insert(Platform::new(Os::Linux, Arch::Arm64));
697 assert_eq!(set.len(), 2);
698 }
699
700 #[test]
701 fn test_os_equality() {
702 assert_eq!(Os::Darwin, Os::Darwin);
703 assert_eq!(Os::Linux, Os::Linux);
704 assert_ne!(Os::Darwin, Os::Linux);
705 }
706
707 #[test]
708 fn test_arch_equality() {
709 assert_eq!(Arch::Arm64, Arch::Arm64);
710 assert_eq!(Arch::X86_64, Arch::X86_64);
711 assert_ne!(Arch::Arm64, Arch::X86_64);
712 }
713}