use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::Result;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Platform {
pub os: Os,
pub arch: Arch,
}
impl Platform {
#[must_use]
pub fn new(os: Os, arch: Arch) -> Self {
Self { os, arch }
}
#[must_use]
pub fn current() -> Self {
Self {
os: Os::current(),
arch: Arch::current(),
}
}
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 2 {
return None;
}
Some(Self {
os: Os::parse(parts[0])?,
arch: Arch::parse(parts[1])?,
})
}
}
impl std::fmt::Display for Platform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}-{}", self.os, self.arch)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Os {
Darwin,
Linux,
}
impl Os {
#[must_use]
pub fn current() -> Self {
#[cfg(target_os = "macos")]
return Self::Darwin;
#[cfg(target_os = "linux")]
return Self::Linux;
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
compile_error!("Unsupported OS");
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"darwin" | "macos" => Some(Self::Darwin),
"linux" => Some(Self::Linux),
_ => None,
}
}
}
impl std::fmt::Display for Os {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Darwin => write!(f, "darwin"),
Self::Linux => write!(f, "linux"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Arch {
Arm64,
X86_64,
}
impl Arch {
#[must_use]
pub fn current() -> Self {
#[cfg(target_arch = "aarch64")]
return Self::Arm64;
#[cfg(target_arch = "x86_64")]
return Self::X86_64;
#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
compile_error!("Unsupported architecture");
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"arm64" | "aarch64" => Some(Self::Arm64),
"x86_64" | "amd64" | "x64" => Some(Self::X86_64),
_ => None,
}
}
}
impl std::fmt::Display for Arch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Arm64 => write!(f, "arm64"),
Self::X86_64 => write!(f, "x86_64"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ToolSource {
Oci { image: String, path: String },
GitHub {
repo: String,
tag: String,
asset: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extract: Vec<ToolExtract>,
},
Nix {
flake: String,
package: String,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
Rustup {
toolchain: String,
#[serde(skip_serializing_if = "Option::is_none")]
profile: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
components: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
targets: Vec<String>,
},
#[serde(rename = "url")]
Url {
url: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extract: Vec<ToolExtract>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum ToolExtract {
Bin {
path: String,
#[serde(rename = "as", skip_serializing_if = "Option::is_none")]
as_name: Option<String>,
},
Lib {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<String>,
},
Include {
path: String,
},
PkgConfig {
path: String,
},
File {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<String>,
},
}
impl ToolSource {
#[must_use]
pub fn provider_type(&self) -> &'static str {
match self {
Self::Oci { .. } => "oci",
Self::GitHub { .. } => "github",
Self::Nix { .. } => "nix",
Self::Rustup { .. } => "rustup",
Self::Url { .. } => "url",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedTool {
pub name: String,
pub version: String,
pub platform: Platform,
pub source: ToolSource,
}
#[derive(Debug)]
pub struct FetchedTool {
pub name: String,
pub binary_path: PathBuf,
pub sha256: String,
}
#[derive(Debug, Clone, Default)]
pub struct ToolOptions {
pub cache_dir: Option<PathBuf>,
pub force_refetch: bool,
}
impl ToolOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_cache_dir(mut self, path: PathBuf) -> Self {
self.cache_dir = Some(path);
self
}
#[must_use]
pub fn with_force_refetch(mut self, force: bool) -> Self {
self.force_refetch = force;
self
}
#[must_use]
pub fn cache_dir(&self) -> PathBuf {
self.cache_dir.clone().unwrap_or_else(default_cache_dir)
}
}
#[must_use]
pub fn default_cache_dir() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("cuenv")
.join("tools")
}
pub struct ToolResolveRequest<'a> {
pub tool_name: &'a str,
pub version: &'a str,
pub platform: &'a Platform,
pub config: &'a serde_json::Value,
pub token: Option<&'a str>,
}
#[async_trait]
pub trait ToolProvider: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn can_handle(&self, source: &ToolSource) -> bool;
async fn resolve(&self, request: &ToolResolveRequest<'_>) -> Result<ResolvedTool>;
async fn fetch(&self, resolved: &ResolvedTool, options: &ToolOptions) -> Result<FetchedTool>;
fn is_cached(&self, resolved: &ResolvedTool, options: &ToolOptions) -> bool;
async fn check_prerequisites(&self) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_platform_parse() {
let p = Platform::parse("darwin-arm64").unwrap();
assert_eq!(p.os, Os::Darwin);
assert_eq!(p.arch, Arch::Arm64);
let p = Platform::parse("linux-x86_64").unwrap();
assert_eq!(p.os, Os::Linux);
assert_eq!(p.arch, Arch::X86_64);
assert!(Platform::parse("invalid").is_none());
}
#[test]
fn test_platform_parse_edge_cases() {
assert!(Platform::parse("darwin").is_none());
assert!(Platform::parse("darwin-arm64-extra").is_none());
assert!(Platform::parse("").is_none());
assert!(Platform::parse("windows-arm64").is_none());
assert!(Platform::parse("darwin-mips").is_none());
}
#[test]
fn test_platform_display() {
let p = Platform::new(Os::Darwin, Arch::Arm64);
assert_eq!(p.to_string(), "darwin-arm64");
}
#[test]
fn test_platform_display_all_combinations() {
assert_eq!(
Platform::new(Os::Darwin, Arch::Arm64).to_string(),
"darwin-arm64"
);
assert_eq!(
Platform::new(Os::Darwin, Arch::X86_64).to_string(),
"darwin-x86_64"
);
assert_eq!(
Platform::new(Os::Linux, Arch::Arm64).to_string(),
"linux-arm64"
);
assert_eq!(
Platform::new(Os::Linux, Arch::X86_64).to_string(),
"linux-x86_64"
);
}
#[test]
fn test_platform_current() {
let p = Platform::current();
assert!(matches!(p.os, Os::Darwin | Os::Linux));
assert!(matches!(p.arch, Arch::Arm64 | Arch::X86_64));
}
#[test]
fn test_os_parse() {
assert_eq!(Os::parse("darwin"), Some(Os::Darwin));
assert_eq!(Os::parse("macos"), Some(Os::Darwin));
assert_eq!(Os::parse("linux"), Some(Os::Linux));
assert_eq!(Os::parse("windows"), None);
}
#[test]
fn test_os_parse_case_insensitive() {
assert_eq!(Os::parse("DARWIN"), Some(Os::Darwin));
assert_eq!(Os::parse("Darwin"), Some(Os::Darwin));
assert_eq!(Os::parse("LINUX"), Some(Os::Linux));
assert_eq!(Os::parse("Linux"), Some(Os::Linux));
assert_eq!(Os::parse("MACOS"), Some(Os::Darwin));
assert_eq!(Os::parse("MacOS"), Some(Os::Darwin));
}
#[test]
fn test_os_display() {
assert_eq!(Os::Darwin.to_string(), "darwin");
assert_eq!(Os::Linux.to_string(), "linux");
}
#[test]
fn test_os_current() {
let os = Os::current();
assert!(matches!(os, Os::Darwin | Os::Linux));
}
#[test]
fn test_arch_parse() {
assert_eq!(Arch::parse("arm64"), Some(Arch::Arm64));
assert_eq!(Arch::parse("aarch64"), Some(Arch::Arm64));
assert_eq!(Arch::parse("x86_64"), Some(Arch::X86_64));
assert_eq!(Arch::parse("amd64"), Some(Arch::X86_64));
}
#[test]
fn test_arch_parse_case_insensitive() {
assert_eq!(Arch::parse("ARM64"), Some(Arch::Arm64));
assert_eq!(Arch::parse("Arm64"), Some(Arch::Arm64));
assert_eq!(Arch::parse("AARCH64"), Some(Arch::Arm64));
assert_eq!(Arch::parse("X86_64"), Some(Arch::X86_64));
assert_eq!(Arch::parse("AMD64"), Some(Arch::X86_64));
}
#[test]
fn test_arch_parse_x64_alias() {
assert_eq!(Arch::parse("x64"), Some(Arch::X86_64));
assert_eq!(Arch::parse("X64"), Some(Arch::X86_64));
}
#[test]
fn test_arch_parse_invalid() {
assert!(Arch::parse("mips").is_none());
assert!(Arch::parse("riscv").is_none());
assert!(Arch::parse("").is_none());
}
#[test]
fn test_arch_display() {
assert_eq!(Arch::Arm64.to_string(), "arm64");
assert_eq!(Arch::X86_64.to_string(), "x86_64");
}
#[test]
fn test_arch_current() {
let arch = Arch::current();
assert!(matches!(arch, Arch::Arm64 | Arch::X86_64));
}
#[test]
fn test_tool_source_provider_type() {
let s = ToolSource::GitHub {
repo: "jqlang/jq".into(),
tag: "jq-1.7.1".into(),
asset: "jq-macos-arm64".into(),
extract: vec![],
};
assert_eq!(s.provider_type(), "github");
let s = ToolSource::Nix {
flake: "nixpkgs".into(),
package: "jq".into(),
output: None,
};
assert_eq!(s.provider_type(), "nix");
let s = ToolSource::Rustup {
toolchain: "1.83.0".into(),
profile: Some("default".into()),
components: vec!["clippy".into(), "rustfmt".into()],
targets: vec!["x86_64-unknown-linux-gnu".into()],
};
assert_eq!(s.provider_type(), "rustup");
let s = ToolSource::Url {
url: "https://example.com/tool-1.0.0.tar.gz".into(),
extract: vec![],
};
assert_eq!(s.provider_type(), "url");
}
#[test]
fn test_tool_source_oci_provider_type() {
let s = ToolSource::Oci {
image: "docker.io/library/alpine:latest".into(),
path: "/usr/bin/jq".into(),
};
assert_eq!(s.provider_type(), "oci");
}
#[test]
fn test_tool_source_serialization() {
let source = ToolSource::GitHub {
repo: "jqlang/jq".into(),
tag: "jq-1.7.1".into(),
asset: "jq-macos-arm64".into(),
extract: vec![ToolExtract::Bin {
path: "jq-macos-arm64/jq".into(),
as_name: None,
}],
};
let json = serde_json::to_string(&source).unwrap();
assert!(json.contains("\"type\":\"github\""));
assert!(json.contains("\"repo\":\"jqlang/jq\""));
assert!(json.contains("\"kind\":\"bin\""));
assert!(json.contains("\"path\":\"jq-macos-arm64/jq\""));
}
#[test]
fn test_tool_source_deserialization() {
let json =
r#"{"type":"github","repo":"jqlang/jq","tag":"jq-1.7.1","asset":"jq-macos-arm64"}"#;
let source: ToolSource = serde_json::from_str(json).unwrap();
match source {
ToolSource::GitHub {
repo, tag, asset, ..
} => {
assert_eq!(repo, "jqlang/jq");
assert_eq!(tag, "jq-1.7.1");
assert_eq!(asset, "jq-macos-arm64");
}
_ => panic!("Expected GitHub source"),
}
}
#[test]
fn test_tool_source_nix_serialization() {
let source = ToolSource::Nix {
flake: "nixpkgs".into(),
package: "jq".into(),
output: Some("bin".into()),
};
let json = serde_json::to_string(&source).unwrap();
assert!(json.contains("\"type\":\"nix\""));
assert!(json.contains("\"output\":\"bin\""));
}
#[test]
fn test_tool_source_rustup_serialization() {
let source = ToolSource::Rustup {
toolchain: "stable".into(),
profile: None,
components: vec![],
targets: vec![],
};
let json = serde_json::to_string(&source).unwrap();
assert!(json.contains("\"type\":\"rustup\""));
assert!(!json.contains("components"));
assert!(!json.contains("targets"));
}
#[test]
fn test_tool_source_url_serialization() {
let source = ToolSource::Url {
url: "https://example.com/tool-1.0.0.tar.gz".into(),
extract: vec![ToolExtract::Bin {
path: "tool".into(),
as_name: None,
}],
};
let json = serde_json::to_string(&source).unwrap();
assert!(json.contains("\"type\":\"url\""));
assert!(json.contains("\"url\":\"https://example.com/tool-1.0.0.tar.gz\""));
assert!(json.contains("\"kind\":\"bin\""));
}
#[test]
fn test_tool_source_url_deserialization() {
let json = r#"{"type":"url","url":"https://example.com/tool-1.0.0.tar.gz"}"#;
let source: ToolSource = serde_json::from_str(json).unwrap();
match source {
ToolSource::Url { url, extract } => {
assert_eq!(url, "https://example.com/tool-1.0.0.tar.gz");
assert!(extract.is_empty());
}
_ => panic!("Expected URL source"),
}
}
#[test]
fn test_resolved_tool_serialization() {
let tool = ResolvedTool {
name: "jq".into(),
version: "1.7.1".into(),
platform: Platform::new(Os::Darwin, Arch::Arm64),
source: ToolSource::GitHub {
repo: "jqlang/jq".into(),
tag: "jq-1.7.1".into(),
asset: "jq-macos-arm64".into(),
extract: vec![],
},
};
let json = serde_json::to_string(&tool).unwrap();
assert!(json.contains("\"name\":\"jq\""));
assert!(json.contains("\"version\":\"1.7.1\""));
}
#[test]
fn test_tool_options_default() {
let opts = ToolOptions::default();
assert!(opts.cache_dir.is_none());
assert!(!opts.force_refetch);
}
#[test]
fn test_tool_options_new() {
let opts = ToolOptions::new();
assert!(opts.cache_dir.is_none());
assert!(!opts.force_refetch);
}
#[test]
fn test_tool_options_builder() {
let opts = ToolOptions::new()
.with_cache_dir(PathBuf::from("/custom/cache"))
.with_force_refetch(true);
assert_eq!(opts.cache_dir, Some(PathBuf::from("/custom/cache")));
assert!(opts.force_refetch);
}
#[test]
fn test_tool_options_cache_dir_default() {
let opts = ToolOptions::new();
let cache_dir = opts.cache_dir();
assert!(cache_dir.ends_with("cuenv/tools"));
}
#[test]
fn test_tool_options_cache_dir_custom() {
let opts = ToolOptions::new().with_cache_dir(PathBuf::from("/my/cache"));
assert_eq!(opts.cache_dir(), PathBuf::from("/my/cache"));
}
#[test]
fn test_default_cache_dir() {
let cache_dir = default_cache_dir();
assert!(cache_dir.ends_with("cuenv/tools"));
}
#[test]
fn test_platform_equality() {
let p1 = Platform::new(Os::Darwin, Arch::Arm64);
let p2 = Platform::new(Os::Darwin, Arch::Arm64);
let p3 = Platform::new(Os::Linux, Arch::Arm64);
assert_eq!(p1, p2);
assert_ne!(p1, p3);
}
#[test]
fn test_platform_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(Platform::new(Os::Darwin, Arch::Arm64));
set.insert(Platform::new(Os::Darwin, Arch::Arm64));
assert_eq!(set.len(), 1);
set.insert(Platform::new(Os::Linux, Arch::Arm64));
assert_eq!(set.len(), 2);
}
#[test]
fn test_os_equality() {
assert_eq!(Os::Darwin, Os::Darwin);
assert_eq!(Os::Linux, Os::Linux);
assert_ne!(Os::Darwin, Os::Linux);
}
#[test]
fn test_arch_equality() {
assert_eq!(Arch::Arm64, Arch::Arm64);
assert_eq!(Arch::X86_64, Arch::X86_64);
assert_ne!(Arch::Arm64, Arch::X86_64);
}
}