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(default, skip_serializing_if = "Vec::is_empty")]
149 extract: Vec<ToolExtract>,
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 #[serde(rename = "url")]
174 Url {
175 url: String,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 extract: Vec<ToolExtract>,
180 },
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185#[serde(tag = "kind", rename_all = "lowercase")]
186pub enum ToolExtract {
187 Bin {
189 path: String,
191 #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
193 as_name: Option<String>,
194 },
195 Lib {
197 path: String,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 env: Option<String>,
202 },
203 Include {
205 path: String,
207 },
208 PkgConfig {
210 path: String,
212 },
213 File {
215 path: String,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 env: Option<String>,
220 },
221}
222
223impl ToolSource {
224 #[must_use]
226 pub fn provider_type(&self) -> &'static str {
227 match self {
228 Self::Oci { .. } => "oci",
229 Self::GitHub { .. } => "github",
230 Self::Nix { .. } => "nix",
231 Self::Rustup { .. } => "rustup",
232 Self::Url { .. } => "url",
233 }
234 }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ResolvedTool {
243 pub name: String,
245 pub version: String,
247 pub platform: Platform,
249 pub source: ToolSource,
251}
252
253#[derive(Debug)]
255pub struct FetchedTool {
256 pub name: String,
258 pub binary_path: PathBuf,
260 pub sha256: String,
262}
263
264#[derive(Debug, Clone, Default)]
266pub struct ToolOptions {
267 pub cache_dir: Option<PathBuf>,
269 pub force_refetch: bool,
271}
272
273impl ToolOptions {
274 #[must_use]
276 pub fn new() -> Self {
277 Self::default()
278 }
279
280 #[must_use]
282 pub fn with_cache_dir(mut self, path: PathBuf) -> Self {
283 self.cache_dir = Some(path);
284 self
285 }
286
287 #[must_use]
289 pub fn with_force_refetch(mut self, force: bool) -> Self {
290 self.force_refetch = force;
291 self
292 }
293
294 #[must_use]
296 pub fn cache_dir(&self) -> PathBuf {
297 self.cache_dir.clone().unwrap_or_else(default_cache_dir)
298 }
299}
300
301#[must_use]
303pub fn default_cache_dir() -> PathBuf {
304 dirs::cache_dir()
305 .unwrap_or_else(|| PathBuf::from(".cache"))
306 .join("cuenv")
307 .join("tools")
308}
309
310pub struct ToolResolveRequest<'a> {
312 pub tool_name: &'a str,
314 pub version: &'a str,
316 pub platform: &'a Platform,
318 pub config: &'a serde_json::Value,
320 pub token: Option<&'a str>,
322}
323
324#[async_trait]
343pub trait ToolProvider: Send + Sync {
344 fn name(&self) -> &'static str;
348
349 fn description(&self) -> &'static str;
351
352 fn can_handle(&self, source: &ToolSource) -> bool;
354
355 async fn resolve(&self, request: &ToolResolveRequest<'_>) -> Result<ResolvedTool>;
369
370 async fn fetch(&self, resolved: &ResolvedTool, options: &ToolOptions) -> Result<FetchedTool>;
385
386 fn is_cached(&self, resolved: &ResolvedTool, options: &ToolOptions) -> bool;
390
391 async fn check_prerequisites(&self) -> Result<()> {
404 Ok(())
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn test_platform_parse() {
414 let p = Platform::parse("darwin-arm64").unwrap();
415 assert_eq!(p.os, Os::Darwin);
416 assert_eq!(p.arch, Arch::Arm64);
417
418 let p = Platform::parse("linux-x86_64").unwrap();
419 assert_eq!(p.os, Os::Linux);
420 assert_eq!(p.arch, Arch::X86_64);
421
422 assert!(Platform::parse("invalid").is_none());
423 }
424
425 #[test]
426 fn test_platform_parse_edge_cases() {
427 assert!(Platform::parse("darwin").is_none());
429 assert!(Platform::parse("darwin-arm64-extra").is_none());
431 assert!(Platform::parse("").is_none());
433 assert!(Platform::parse("windows-arm64").is_none());
435 assert!(Platform::parse("darwin-mips").is_none());
437 }
438
439 #[test]
440 fn test_platform_display() {
441 let p = Platform::new(Os::Darwin, Arch::Arm64);
442 assert_eq!(p.to_string(), "darwin-arm64");
443 }
444
445 #[test]
446 fn test_platform_display_all_combinations() {
447 assert_eq!(
448 Platform::new(Os::Darwin, Arch::Arm64).to_string(),
449 "darwin-arm64"
450 );
451 assert_eq!(
452 Platform::new(Os::Darwin, Arch::X86_64).to_string(),
453 "darwin-x86_64"
454 );
455 assert_eq!(
456 Platform::new(Os::Linux, Arch::Arm64).to_string(),
457 "linux-arm64"
458 );
459 assert_eq!(
460 Platform::new(Os::Linux, Arch::X86_64).to_string(),
461 "linux-x86_64"
462 );
463 }
464
465 #[test]
466 fn test_platform_current() {
467 let p = Platform::current();
468 assert!(matches!(p.os, Os::Darwin | Os::Linux));
470 assert!(matches!(p.arch, Arch::Arm64 | Arch::X86_64));
471 }
472
473 #[test]
474 fn test_os_parse() {
475 assert_eq!(Os::parse("darwin"), Some(Os::Darwin));
476 assert_eq!(Os::parse("macos"), Some(Os::Darwin));
477 assert_eq!(Os::parse("linux"), Some(Os::Linux));
478 assert_eq!(Os::parse("windows"), None);
479 }
480
481 #[test]
482 fn test_os_parse_case_insensitive() {
483 assert_eq!(Os::parse("DARWIN"), Some(Os::Darwin));
484 assert_eq!(Os::parse("Darwin"), Some(Os::Darwin));
485 assert_eq!(Os::parse("LINUX"), Some(Os::Linux));
486 assert_eq!(Os::parse("Linux"), Some(Os::Linux));
487 assert_eq!(Os::parse("MACOS"), Some(Os::Darwin));
488 assert_eq!(Os::parse("MacOS"), Some(Os::Darwin));
489 }
490
491 #[test]
492 fn test_os_display() {
493 assert_eq!(Os::Darwin.to_string(), "darwin");
494 assert_eq!(Os::Linux.to_string(), "linux");
495 }
496
497 #[test]
498 fn test_os_current() {
499 let os = Os::current();
500 assert!(matches!(os, Os::Darwin | Os::Linux));
502 }
503
504 #[test]
505 fn test_arch_parse() {
506 assert_eq!(Arch::parse("arm64"), Some(Arch::Arm64));
507 assert_eq!(Arch::parse("aarch64"), Some(Arch::Arm64));
508 assert_eq!(Arch::parse("x86_64"), Some(Arch::X86_64));
509 assert_eq!(Arch::parse("amd64"), Some(Arch::X86_64));
510 }
511
512 #[test]
513 fn test_arch_parse_case_insensitive() {
514 assert_eq!(Arch::parse("ARM64"), Some(Arch::Arm64));
515 assert_eq!(Arch::parse("Arm64"), Some(Arch::Arm64));
516 assert_eq!(Arch::parse("AARCH64"), Some(Arch::Arm64));
517 assert_eq!(Arch::parse("X86_64"), Some(Arch::X86_64));
518 assert_eq!(Arch::parse("AMD64"), Some(Arch::X86_64));
519 }
520
521 #[test]
522 fn test_arch_parse_x64_alias() {
523 assert_eq!(Arch::parse("x64"), Some(Arch::X86_64));
524 assert_eq!(Arch::parse("X64"), Some(Arch::X86_64));
525 }
526
527 #[test]
528 fn test_arch_parse_invalid() {
529 assert!(Arch::parse("mips").is_none());
530 assert!(Arch::parse("riscv").is_none());
531 assert!(Arch::parse("").is_none());
532 }
533
534 #[test]
535 fn test_arch_display() {
536 assert_eq!(Arch::Arm64.to_string(), "arm64");
537 assert_eq!(Arch::X86_64.to_string(), "x86_64");
538 }
539
540 #[test]
541 fn test_arch_current() {
542 let arch = Arch::current();
543 assert!(matches!(arch, Arch::Arm64 | Arch::X86_64));
545 }
546
547 #[test]
548 fn test_tool_source_provider_type() {
549 let s = ToolSource::GitHub {
550 repo: "jqlang/jq".into(),
551 tag: "jq-1.7.1".into(),
552 asset: "jq-macos-arm64".into(),
553 extract: vec![],
554 };
555 assert_eq!(s.provider_type(), "github");
556
557 let s = ToolSource::Nix {
558 flake: "nixpkgs".into(),
559 package: "jq".into(),
560 output: None,
561 };
562 assert_eq!(s.provider_type(), "nix");
563
564 let s = ToolSource::Rustup {
565 toolchain: "1.83.0".into(),
566 profile: Some("default".into()),
567 components: vec!["clippy".into(), "rustfmt".into()],
568 targets: vec!["x86_64-unknown-linux-gnu".into()],
569 };
570 assert_eq!(s.provider_type(), "rustup");
571
572 let s = ToolSource::Url {
573 url: "https://example.com/tool-1.0.0.tar.gz".into(),
574 extract: vec![],
575 };
576 assert_eq!(s.provider_type(), "url");
577 }
578
579 #[test]
580 fn test_tool_source_oci_provider_type() {
581 let s = ToolSource::Oci {
582 image: "docker.io/library/alpine:latest".into(),
583 path: "/usr/bin/jq".into(),
584 };
585 assert_eq!(s.provider_type(), "oci");
586 }
587
588 #[test]
589 fn test_tool_source_serialization() {
590 let source = ToolSource::GitHub {
591 repo: "jqlang/jq".into(),
592 tag: "jq-1.7.1".into(),
593 asset: "jq-macos-arm64".into(),
594 extract: vec![ToolExtract::Bin {
595 path: "jq-macos-arm64/jq".into(),
596 as_name: None,
597 }],
598 };
599 let json = serde_json::to_string(&source).unwrap();
600 assert!(json.contains("\"type\":\"github\""));
601 assert!(json.contains("\"repo\":\"jqlang/jq\""));
602 assert!(json.contains("\"kind\":\"bin\""));
603 assert!(json.contains("\"path\":\"jq-macos-arm64/jq\""));
604 }
605
606 #[test]
607 fn test_tool_source_deserialization() {
608 let json =
609 r#"{"type":"github","repo":"jqlang/jq","tag":"jq-1.7.1","asset":"jq-macos-arm64"}"#;
610 let source: ToolSource = serde_json::from_str(json).unwrap();
611 match source {
612 ToolSource::GitHub {
613 repo, tag, asset, ..
614 } => {
615 assert_eq!(repo, "jqlang/jq");
616 assert_eq!(tag, "jq-1.7.1");
617 assert_eq!(asset, "jq-macos-arm64");
618 }
619 _ => panic!("Expected GitHub source"),
620 }
621 }
622
623 #[test]
624 fn test_tool_source_nix_serialization() {
625 let source = ToolSource::Nix {
626 flake: "nixpkgs".into(),
627 package: "jq".into(),
628 output: Some("bin".into()),
629 };
630 let json = serde_json::to_string(&source).unwrap();
631 assert!(json.contains("\"type\":\"nix\""));
632 assert!(json.contains("\"output\":\"bin\""));
633 }
634
635 #[test]
636 fn test_tool_source_rustup_serialization() {
637 let source = ToolSource::Rustup {
638 toolchain: "stable".into(),
639 profile: None,
640 components: vec![],
641 targets: vec![],
642 };
643 let json = serde_json::to_string(&source).unwrap();
644 assert!(json.contains("\"type\":\"rustup\""));
645 assert!(!json.contains("components"));
647 assert!(!json.contains("targets"));
648 }
649
650 #[test]
651 fn test_tool_source_url_serialization() {
652 let source = ToolSource::Url {
653 url: "https://example.com/tool-1.0.0.tar.gz".into(),
654 extract: vec![ToolExtract::Bin {
655 path: "tool".into(),
656 as_name: None,
657 }],
658 };
659 let json = serde_json::to_string(&source).unwrap();
660 assert!(json.contains("\"type\":\"url\""));
661 assert!(json.contains("\"url\":\"https://example.com/tool-1.0.0.tar.gz\""));
662 assert!(json.contains("\"kind\":\"bin\""));
663 }
664
665 #[test]
666 fn test_tool_source_url_deserialization() {
667 let json = r#"{"type":"url","url":"https://example.com/tool-1.0.0.tar.gz"}"#;
668 let source: ToolSource = serde_json::from_str(json).unwrap();
669 match source {
670 ToolSource::Url { url, extract } => {
671 assert_eq!(url, "https://example.com/tool-1.0.0.tar.gz");
672 assert!(extract.is_empty());
673 }
674 _ => panic!("Expected URL source"),
675 }
676 }
677
678 #[test]
679 fn test_resolved_tool_serialization() {
680 let tool = ResolvedTool {
681 name: "jq".into(),
682 version: "1.7.1".into(),
683 platform: Platform::new(Os::Darwin, Arch::Arm64),
684 source: ToolSource::GitHub {
685 repo: "jqlang/jq".into(),
686 tag: "jq-1.7.1".into(),
687 asset: "jq-macos-arm64".into(),
688 extract: vec![],
689 },
690 };
691 let json = serde_json::to_string(&tool).unwrap();
692 assert!(json.contains("\"name\":\"jq\""));
693 assert!(json.contains("\"version\":\"1.7.1\""));
694 }
695
696 #[test]
697 fn test_tool_options_default() {
698 let opts = ToolOptions::default();
699 assert!(opts.cache_dir.is_none());
700 assert!(!opts.force_refetch);
701 }
702
703 #[test]
704 fn test_tool_options_new() {
705 let opts = ToolOptions::new();
706 assert!(opts.cache_dir.is_none());
707 assert!(!opts.force_refetch);
708 }
709
710 #[test]
711 fn test_tool_options_builder() {
712 let opts = ToolOptions::new()
713 .with_cache_dir(PathBuf::from("/custom/cache"))
714 .with_force_refetch(true);
715
716 assert_eq!(opts.cache_dir, Some(PathBuf::from("/custom/cache")));
717 assert!(opts.force_refetch);
718 }
719
720 #[test]
721 fn test_tool_options_cache_dir_default() {
722 let opts = ToolOptions::new();
723 let cache_dir = opts.cache_dir();
724 assert!(cache_dir.ends_with("cuenv/tools"));
726 }
727
728 #[test]
729 fn test_tool_options_cache_dir_custom() {
730 let opts = ToolOptions::new().with_cache_dir(PathBuf::from("/my/cache"));
731 assert_eq!(opts.cache_dir(), PathBuf::from("/my/cache"));
732 }
733
734 #[test]
735 fn test_default_cache_dir() {
736 let cache_dir = default_cache_dir();
737 assert!(cache_dir.ends_with("cuenv/tools"));
739 }
740
741 #[test]
742 fn test_platform_equality() {
743 let p1 = Platform::new(Os::Darwin, Arch::Arm64);
744 let p2 = Platform::new(Os::Darwin, Arch::Arm64);
745 let p3 = Platform::new(Os::Linux, Arch::Arm64);
746
747 assert_eq!(p1, p2);
748 assert_ne!(p1, p3);
749 }
750
751 #[test]
752 fn test_platform_hash() {
753 use std::collections::HashSet;
754
755 let mut set = HashSet::new();
756 set.insert(Platform::new(Os::Darwin, Arch::Arm64));
757 set.insert(Platform::new(Os::Darwin, Arch::Arm64)); assert_eq!(set.len(), 1);
760
761 set.insert(Platform::new(Os::Linux, Arch::Arm64));
762 assert_eq!(set.len(), 2);
763 }
764
765 #[test]
766 fn test_os_equality() {
767 assert_eq!(Os::Darwin, Os::Darwin);
768 assert_eq!(Os::Linux, Os::Linux);
769 assert_ne!(Os::Darwin, Os::Linux);
770 }
771
772 #[test]
773 fn test_arch_equality() {
774 assert_eq!(Arch::Arm64, Arch::Arm64);
775 assert_eq!(Arch::X86_64, Arch::X86_64);
776 assert_ne!(Arch::Arm64, Arch::X86_64);
777 }
778}