From 22f8805a9a7dd19104d2954f372f17b3cc5ead12 Mon Sep 17 00:00:00 2001 From: David Anyatonwu Date: Tue, 21 Jan 2025 22:09:34 +0100 Subject: [PATCH] feat: Add asdf plugin support - Implement plugin interface, version detection, plugin management and tool installation --- Cargo.lock | 13 + tools/asdf/Cargo.toml | 31 ++ tools/asdf/src/config.rs | 11 + tools/asdf/src/lib.rs | 7 + tools/asdf/src/proto.rs | 720 ++++++++++++++++++++++++++++++ tools/asdf/tests/download_test.rs | 129 ++++++ tools/asdf/tests/metadata_test.rs | 101 +++++ tools/asdf/tests/versions_test.rs | 153 +++++++ 8 files changed, 1165 insertions(+) create mode 100644 tools/asdf/Cargo.toml create mode 100644 tools/asdf/src/config.rs create mode 100644 tools/asdf/src/lib.rs create mode 100644 tools/asdf/src/proto.rs create mode 100644 tools/asdf/tests/download_test.rs create mode 100644 tools/asdf/tests/metadata_test.rs create mode 100644 tools/asdf/tests/versions_test.rs diff --git a/Cargo.lock b/Cargo.lock index d3439be..aa10776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2656,6 +2656,19 @@ dependencies = [ "syn", ] +[[package]] +name = "proto-asdf-plugin" +version = "0.1.0" +dependencies = [ + "extism-pdk", + "proto_pdk", + "proto_pdk_test_utils", + "rustc-hash", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "proto_core" version = "0.44.2" diff --git a/tools/asdf/Cargo.toml b/tools/asdf/Cargo.toml new file mode 100644 index 0000000..efdb944 --- /dev/null +++ b/tools/asdf/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "proto-asdf-plugin" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +crate-type = ['cdylib'] + +[dependencies] +proto_pdk = { workspace = true } +extism-pdk = { workspace = true } +serde = { workspace = true } +serde_json = "1.0" +rustc-hash = "2.1" + +[profile.release] +codegen-units = 1 +debug = false +lto = true +opt-level = "s" +panic = "abort" + +[dev-dependencies] +proto_pdk_test_utils = { workspace = true } +tokio = { workspace = true } + +[features] +default = ["wasm"] +wasm = [] diff --git a/tools/asdf/src/config.rs b/tools/asdf/src/config.rs new file mode 100644 index 0000000..19ee093 --- /dev/null +++ b/tools/asdf/src/config.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct AsdfPluginConfig { + #[serde(default)] + pub asdf_plugin: Option, + #[serde(default)] + pub asdf_repository: Option, + #[serde(default)] + pub asdf_version: Option, +} diff --git a/tools/asdf/src/lib.rs b/tools/asdf/src/lib.rs new file mode 100644 index 0000000..ade8d64 --- /dev/null +++ b/tools/asdf/src/lib.rs @@ -0,0 +1,7 @@ +mod config; +mod proto; + +pub use config::*; +pub use proto::*; + + diff --git a/tools/asdf/src/proto.rs b/tools/asdf/src/proto.rs new file mode 100644 index 0000000..c90493e --- /dev/null +++ b/tools/asdf/src/proto.rs @@ -0,0 +1,720 @@ +use crate::config::AsdfPluginConfig; +use extism_pdk::*; +use proto_pdk::*; +use rustc_hash::FxHashMap; +use serde::Serialize; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +#[host_fn] +extern "ExtismHost" { + fn exec_command(input: Json) -> Json; + fn get_env_var(key: &str) -> String; + fn to_virtual_path(input: String) -> String; + fn from_virtual_path(path: String) -> String; + fn host_log(input: Json); +} + +#[derive(Debug, Serialize)] +pub struct AsdfPlugin { + asdf_home: PathBuf, + config: Option, +} + +impl AsdfPlugin { + fn new() -> Self { + // Try ASDF_DATA_DIR first + let asdf_home = if let Ok(data_dir) = unsafe { get_env_var("ASDF_DATA_DIR") } { + PathBuf::from(data_dir) + } else { + // Then try HOME/.asdf + if let Ok(home) = unsafe { get_env_var("HOME") } { + let home_asdf = PathBuf::from(home).join(".asdf"); + if home_asdf.exists() { + home_asdf + } else { + // Finally try ASDF_DIR + if let Ok(asdf_dir) = unsafe { get_env_var("ASDF_DIR") } { + PathBuf::from(asdf_dir) + } else { + home_asdf // Default to HOME/.asdf if nothing else works + } + } + } else { + PathBuf::from("/.asdf") // Fallback if no home directory + } + }; + + Self { + asdf_home, + config: None, + } + } + + fn get_config_file(&self) -> PathBuf { + // Try ASDF_CONFIG_FILE first + if let Ok(config_file) = unsafe { get_env_var("ASDF_CONFIG_FILE") } { + PathBuf::from(config_file) + } else { + // Default to HOME/.asdfrc + if let Ok(home) = unsafe { get_env_var("HOME") } { + PathBuf::from(home).join(".asdfrc") + } else { + PathBuf::from("/.asdfrc") + } + } + } + + fn create_plugin_scripts(&self, plugin_dir: &Path) -> Result<(), Error> { + let bin_dir = plugin_dir.join("bin"); + + // Create required scripts with more comprehensive templates + let scripts = [ + // Required scripts + ("list-all", r#"#!/usr/bin/env bash +set -euo pipefail + +# Fetch all available versions +# This is a basic implementation. Plugin should customize this. +# Some plugins might need to: +# - Parse HTML pages +# - Use API endpoints +# - Check multiple sources +git ls-remote --tags origin | grep -o 'refs/tags/.*' | cut -d/ -f3- | grep -v '\^{}' | sort -V"#), + + ("download", r#"#!/usr/bin/env bash +set -euo pipefail + +# Required environment variables +if [ -z "${ASDF_DOWNLOAD_PATH:-}" ]; then + echo "ASDF_DOWNLOAD_PATH is required" >&2 + exit 1 +fi + +if [ -z "${ASDF_INSTALL_VERSION:-}" ]; then + echo "ASDF_INSTALL_VERSION is required" >&2 + exit 1 +fi + +mkdir -p "$ASDF_DOWNLOAD_PATH" + +# Plugin should implement download strategy: +# - Direct download with curl/wget +# - Git clone specific tag +# - API-based download +# - Platform-specific binaries +echo "Plugin must implement download strategy" >&2 +exit 1"#), + + ("install", r#"#!/usr/bin/env bash +set -euo pipefail + +# Required environment variables +if [ -z "${ASDF_INSTALL_PATH:-}" ]; then + echo "ASDF_INSTALL_PATH is required" >&2 + exit 1 +fi + +if [ -z "${ASDF_INSTALL_VERSION:-}" ]; then + echo "ASDF_INSTALL_VERSION is required" >&2 + exit 1 +fi + +if [ -z "${ASDF_DOWNLOAD_PATH:-}" ]; then + echo "ASDF_DOWNLOAD_PATH is required" >&2 + exit 1 +fi + +mkdir -p "$ASDF_INSTALL_PATH" + +# Plugin should implement installation: +# - Compilation from source +# - Binary installation +# - Dependencies setup +# - Platform-specific steps +echo "Plugin must implement installation strategy" >&2 +exit 1"#), + + // Recommended scripts + ("latest-stable", r#"#!/usr/bin/env bash +set -euo pipefail + +# Get latest stable version +# Plugin should customize this based on: +# - Version naming conventions +# - Release channels +# - Platform support +current_script_path=${BASH_SOURCE[0]} +plugin_dir=$(dirname "$(dirname "$current_script_path")") + +# Default implementation uses list-all +"$plugin_dir/bin/list-all" | grep -v '[a-zA-Z]' | tail -n1"#), + + // Optional but commonly needed scripts + ("list-bin-paths", r#"#!/usr/bin/env bash +set -euo pipefail + +# List directories containing executables +# Plugin should customize based on: +# - Tool's directory structure +# - Multiple binary locations +# - Platform-specific paths +echo "bin" # Default to bin directory"#), + + ("exec-env", r#"#!/usr/bin/env bash +set -euo pipefail + +# Setup environment for execution +# Plugin should set: +# - PATH additions +# - Tool-specific env vars +# - Dependencies' env vars"#), + + ("list-legacy-filenames", r#"#!/usr/bin/env bash +set -euo pipefail + +# List legacy version files +# Examples: +# .ruby-version +# .node-version +# .python-version +echo "" # Plugin should customize"#), + + ("parse-legacy-file", r#"#!/usr/bin/env bash +set -euo pipefail + +# Parse legacy version file +# Input: $1 (legacy file path) +# Output: version string +if [ -f "$1" ]; then + cat "$1" +fi"#), + ]; + + for (name, content) in scripts { + let script_path = bin_dir.join(name); + fs::write(&script_path, content)?; + + // Make script executable + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&script_path)? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms)?; + } + } + + Ok(()) + } + + fn handle_command_error(output: &ExecCommandOutput) -> Result<(), Error> { + if output.exit_code != 0 { + Err(Error::msg(output.stderr.clone())) + } else { + Ok(()) + } + } + + fn ensure_plugin_installed(&self, config: &AsdfPluginConfig, tool: &str) -> Result<(), Error> { + let plugin_name = config.asdf_plugin.as_deref().unwrap_or(tool); + let plugins_dir = self.asdf_home.join("plugins"); + + if !plugins_dir.exists() { + fs::create_dir_all(&plugins_dir)?; + } + + let plugin_dir = plugins_dir.join(plugin_name); + if !plugin_dir.exists() { + let repo_url = config.asdf_repository.clone() + .unwrap_or_else(|| format!("https://github.com/asdf-vm/asdf-{}.git", plugin_name)); + + let plugin_dir_str = plugin_dir.display().to_string(); + let virtual_path = virtual_path!(buf, plugin_dir); + + let mut env = FxHashMap::default(); + env.insert("ASDF_INSTALL_TYPE".to_string(), "version".to_string()); + env.insert("ASDF_INSTALL_VERSION".to_string(), config.asdf_version.clone().unwrap_or_else(|| "latest".to_string())); + + let install_path = self.asdf_home.join("installs").join(plugin_name).join(config.asdf_version.clone().unwrap_or_else(|| "latest".to_string())); + let download_path = self.asdf_home.join("downloads").join(plugin_name).join(config.asdf_version.clone().unwrap_or_else(|| "latest".to_string())); + + env.insert("ASDF_INSTALL_PATH".to_string(), install_path.display().to_string()); + env.insert("ASDF_DOWNLOAD_PATH".to_string(), download_path.display().to_string()); + env.insert("ASDF_PLUGIN_PATH".to_string(), plugin_dir.display().to_string()); + + if let Some(repo) = &config.asdf_repository { + env.insert("ASDF_PLUGIN_SOURCE_URL".to_string(), repo.clone()); + } else { + env.insert( + "ASDF_PLUGIN_SOURCE_URL".to_string(), + format!("https://github.com/asdf-vm/asdf-{}.git", plugin_name) + ); + } + + env.insert("ASDF_CMD_FILE".to_string(), virtual_path.to_string()); + + if let Ok(cpus) = std::thread::available_parallelism() { + env.insert("ASDF_CONCURRENCY".to_string(), cpus.get().to_string()); + } + + let output = exec_command!( + input, + ExecCommandInput { + command: "git".into(), + args: vec!["clone".into(), repo_url, virtual_path.to_string()], + env, + set_executable: true, + ..ExecCommandInput::default() + } + ); + + if output.exit_code != 0 { + return Err(Error::msg(output.stderr)); + } + + let bin_dir = plugin_dir.join("bin"); + fs::create_dir_all(&bin_dir)?; + + self.create_plugin_scripts(&plugin_dir)?; + } + + Ok(()) + } + + fn run_plugin_script(&self, plugin_name: &str, script: &str, version: &Version) -> Result<(), Error> { + let plugin_dir = self.asdf_home.join("plugins").join(plugin_name); + let script_path = plugin_dir.join("bin").join(script); + + if !script_path.exists() { + return Err(Error::msg(format!( + "Plugin script {} not found for {}", + script, plugin_name + ))); + } + + let install_path = self.asdf_home.join("installs").join(plugin_name).join(version.to_string()); + let download_path = self.asdf_home.join("downloads").join(plugin_name).join(version.to_string()); + + let mut env = FxHashMap::default(); + env.insert("ASDF_INSTALL_TYPE".to_string(), "version".to_string()); + env.insert("ASDF_INSTALL_VERSION".to_string(), version.to_string()); + env.insert("ASDF_INSTALL_PATH".to_string(), install_path.display().to_string()); + env.insert("ASDF_DOWNLOAD_PATH".to_string(), download_path.display().to_string()); + env.insert("ASDF_PLUGIN_PATH".to_string(), plugin_dir.display().to_string()); + + if let Some(config) = &self.config { + if let Some(repo) = &config.asdf_repository { + env.insert("ASDF_PLUGIN_SOURCE_URL".to_string(), repo.clone()); + } else { + env.insert( + "ASDF_PLUGIN_SOURCE_URL".to_string(), + format!("https://github.com/asdf-vm/asdf-{}.git", plugin_name) + ); + } + } + + let script_path_str = script_path.display().to_string(); + let script_virtual_path = virtual_path!(buf, script_path); + env.insert("ASDF_CMD_FILE".to_string(), script_path.display().to_string()); + + if let Ok(cpus) = std::thread::available_parallelism() { + env.insert("ASDF_CONCURRENCY".to_string(), cpus.get().to_string()); + } + + let output = exec_command!( + input, + ExecCommandInput { + command: script_virtual_path.to_string(), + env, + set_executable: true, + ..ExecCommandInput::default() + } + ); + + if output.exit_code != 0 { + // let error_msg = String::from_utf8_lossy(&output.stderr); + return Err(Error::msg(output.stderr)); + } + + Ok(()) + } + + fn parse_tool_versions(&self, path: &Path, tool_name: &str) -> Result, Error> { + // If it's a .tool-versions file, parse normally + if path.file_name().and_then(|f| f.to_str()) == Some(&self.get_tool_versions_filename()) { + let content = fs::read_to_string(path)?; + let mut versions = Vec::new(); + + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 && parts[0] == tool_name { + for version_str in &parts[1..] { + if let Ok(version) = Version::parse(version_str) { + versions.push(version); + } + } + } + } + + return Ok(versions); + } + + // For legacy files, use the plugin's parse-legacy-file script + let tool_name_string = tool_name.to_string(); + let plugin_name = self.config.as_ref() + .and_then(|c| c.asdf_plugin.as_ref()) + .unwrap_or(&tool_name_string); + + let script_path = self.asdf_home + .join("plugins") + .join(plugin_name) + .join("bin") + .join("parse-legacy-file"); + + if script_path.exists() { + let output = exec_command!( + input, + ExecCommandInput { + command: script_path.display().to_string(), + args: vec![path.display().to_string()], + ..ExecCommandInput::default() + } + ); + + if output.exit_code == 0 { + if let Ok(version) = Version::parse(output.stdout.trim()) { + return Ok(vec![version]); + } + } + } + + Ok(Vec::new()) + } + + fn get_tool_versions_filename(&self) -> String { + unsafe { get_env_var("ASDF_DEFAULT_TOOL_VERSIONS_FILENAME") } + .unwrap_or_else(|_| ".tool-versions".to_string()) + } + + fn find_tool_versions(&self, dir: &Path) -> Vec { + let mut tool_versions_files = Vec::new(); + let mut current = Some(PathBuf::from(dir)); + let filename = self.get_tool_versions_filename(); + + while let Some(dir) = current { + let tool_versions = dir.join(&filename); + if tool_versions.exists() { + tool_versions_files.push(tool_versions); + } + current = dir.parent().map(|p| p.to_path_buf()); + } + + if let Ok(home) = unsafe { get_env_var("HOME") } { + let global_tool_versions = PathBuf::from(home).join(&filename); + if global_tool_versions.exists() { + tool_versions_files.push(global_tool_versions); + } + } + + tool_versions_files + } + + fn update_plugin(&self, plugin_name: &str) -> Result<(), Error> { + let plugin_dir = self.asdf_home.join("plugins").join(plugin_name); + if !plugin_dir.exists() { + return Ok(()); // Nothing to update + } + + let plugin_dir_str = plugin_dir.display().to_string(); + let virtual_path = virtual_path!(buf, plugin_dir); + + // Get current ref + let output = exec_command!( + input, + ExecCommandInput { + command: "git".into(), + args: vec!["rev-parse".into(), "HEAD".into()], + working_dir: Some(virtual_path.clone()), + ..ExecCommandInput::default() + } + ); + + let prev_ref = if output.exit_code == 0 { + output.stdout + } else { + String::new() + }; + + // Fetch and update + let output = exec_command!( + input, + ExecCommandInput { + command: "git".into(), + args: vec!["pull".into(), "origin".into(), "master".into()], + working_dir: Some(virtual_path.clone()), + ..ExecCommandInput::default() + } + ); + + if output.exit_code != 0 { + return Err(Error::msg(output.stderr)); + } + + // Get updated ref + let output = exec_command!( + input, + ExecCommandInput { + command: "git".into(), + args: vec!["rev-parse".into(), "HEAD".into()], + working_dir: Some(virtual_path.clone()), + ..ExecCommandInput::default() + } + ); + + let post_ref = if output.exit_code == 0 { + output.stdout + } else { + String::new() + }; + + // Run post-plugin-update script if it exists + let script_path = plugin_dir.join("bin").join("post-plugin-update"); + if script_path.exists() { + let mut env = FxHashMap::default(); + env.insert("ASDF_PLUGIN_PATH".to_string(), virtual_path.to_string()); + env.insert("ASDF_PLUGIN_PREV_REF".to_string(), prev_ref); + env.insert("ASDF_PLUGIN_POST_REF".to_string(), post_ref); + + let script_path_str = script_path.display().to_string(); + let script_virtual_path: VirtualPath = virtual_path!(buf, script_path); + env.insert("ASDF_CMD_FILE".to_string(), script_path.display().to_string()); + + let output = exec_command!( + input, + ExecCommandInput { + command: script_virtual_path.to_string(), + env, + set_executable: true, + ..ExecCommandInput::default() + } + ); + + if output.exit_code != 0 { + return Err(Error::msg(output.stderr)); + } + } + + Ok(()) + } +} + + +#[plugin_fn] +pub fn register_tool(Json(_): Json) -> FnResult> { + Ok(Json(ToolMetadataOutput { + name: "asdf".into(), + type_of: PluginType::Language, + minimum_proto_version: Some(Version::new(0, 42, 0)), + plugin_version: Version::parse(env!("CARGO_PKG_VERSION")).ok(), + ..ToolMetadataOutput::default() + })) +} + +#[plugin_fn] +pub fn detect_version_files(_: ()) -> FnResult> { + let plugin = AsdfPlugin::new(); + let config = plugin.config.as_ref().ok_or_else(|| { + Error::msg("Plugin configuration not available") + })?; + + let plugin_id = get_plugin_id()?.to_string(); + let plugin_name = config.asdf_plugin.as_deref().unwrap_or(&plugin_id); + + // Get standard .tool-versions file + let mut files = vec![plugin.get_tool_versions_filename()]; + + // Get legacy version files from plugin + let script_path = plugin.asdf_home + .join("plugins") + .join(plugin_name) + .join("bin") + .join("list-legacy-filenames"); + + if script_path.exists() { + let output = exec_command!( + input, + ExecCommandInput { + command: script_path.display().to_string(), + ..ExecCommandInput::default() + } + ); + + if output.exit_code == 0 { + files.extend( + output.stdout + .split_whitespace() + .map(|s| s.to_string()) + ); + } + } + + Ok(Json(DetectVersionOutput { + files: files.into_iter().map(Into::into).collect(), + ignore: vec![], + })) +} + +#[plugin_fn] +pub fn download(Json(input): Json) -> FnResult> { + let plugin = AsdfPlugin::new(); + let config = plugin.config.as_ref().ok_or_else(|| { + Error::msg("Plugin configuration not available") + })?; + + let plugin_id = get_plugin_id()?.to_string(); + let plugin_name = config.asdf_plugin.as_deref().unwrap_or(&plugin_id); + plugin.ensure_plugin_installed(config, plugin_name)?; + + let version = Version::parse(&input.version.to_string()) + .map_err(|e| Error::msg(format!("Invalid version: {}", e)))?; + + let download_path = plugin.asdf_home + .join("downloads") + .join(plugin_name) + .join(version.to_string()); + fs::create_dir_all(&download_path) + .map_err(|e| Error::msg(e.to_string()))?; + + plugin.run_plugin_script(plugin_name, "download", &version)?; + Ok(Json(())) +} + +#[plugin_fn] +pub fn install(Json(input): Json) -> FnResult> { + let plugin = AsdfPlugin::new(); + let config = plugin.config.as_ref().ok_or_else(|| { + Error::msg("Plugin configuration not available") + })?; + + let plugin_id = get_plugin_id()?.to_string(); + let plugin_name = config.asdf_plugin.as_deref().unwrap_or(&plugin_id); + + let version = Version::parse(&input.version.to_string()) + .map_err(|e| Error::msg(format!("Invalid version: {}", e)))?; + + let install_path = plugin.asdf_home + .join("installs") + .join(plugin_name) + .join(version.to_string()); + fs::create_dir_all(&install_path) + .map_err(|e| Error::msg(e.to_string()))?; + + plugin.run_plugin_script(plugin_name, "install", &version)?; + Ok(Json(())) +} + +#[plugin_fn] +pub fn parse_version_file( + Json(input): Json, +) -> FnResult> { + let mut version = None; + + if input.file == ".tool-versions" { + for line in input.content.lines() { + let line = line.trim(); + if !line.is_empty() && !line.starts_with('#') { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + version = Some(UnresolvedVersionSpec::parse(parts[1].to_string())?); + break; + } + } + } + } + + Ok(Json(ParseVersionFileOutput { version })) +} + +#[plugin_fn] +pub fn load_versions(_: Json) -> FnResult> { + let plugin = AsdfPlugin::new(); + let config = plugin.config.as_ref().ok_or_else(|| { + Error::msg("Plugin configuration not available") + })?; + + let plugin_id = get_plugin_id()?.to_string(); + let plugin_name = config.asdf_plugin.as_deref().unwrap_or(&plugin_id); + plugin.ensure_plugin_installed(config, plugin_name)?; + + // Update plugin to get latest versions + plugin.update_plugin(plugin_name)?; + + // Run list-all script to get available versions + let script_path = plugin.asdf_home + .join("plugins") + .join(plugin_name) + .join("bin") + .join("list-all"); + + let output = exec_command!( + input, + ExecCommandInput { + command: script_path.display().to_string(), + ..ExecCommandInput::default() + } + ); + + if let Err(e) = AsdfPlugin::handle_command_error(&output) { + return Err(e.into()); + } + + let mut versions = Vec::new(); + let mut latest = None; + let mut aliases = FxHashMap::default(); + + // Parse versions from list-all output + for version_str in output.stdout.split_whitespace() { + if let Ok(version) = Version::parse(version_str) { + if let Ok(version_spec) = VersionSpec::parse(&version.to_string()) { + versions.push(version_spec); + } + } + } + + // Get latest stable version + let latest_script = plugin.asdf_home + .join("plugins") + .join(plugin_name) + .join("bin") + .join("latest-stable"); + + if latest_script.exists() { + let output = exec_command!( + input, + ExecCommandInput { + command: latest_script.display().to_string(), + ..ExecCommandInput::default() + } + ); + + if output.exit_code == 0 { + if let Ok(version) = Version::parse(output.stdout.trim()) { + if let Ok(version_spec) = UnresolvedVersionSpec::parse(&version.to_string()) { + latest = Some(version_spec.clone()); + aliases.insert("latest".to_string(), version_spec); + } + } + } + } + + Ok(Json(LoadVersionsOutput { + versions, + latest, + aliases, + canary: None, + })) +} \ No newline at end of file diff --git a/tools/asdf/tests/download_test.rs b/tools/asdf/tests/download_test.rs new file mode 100644 index 0000000..9aa190f --- /dev/null +++ b/tools/asdf/tests/download_test.rs @@ -0,0 +1,129 @@ +use proto_pdk_test_utils::*; +use std::fs; +use std::os::unix::fs::PermissionsExt; + +mod asdf_tool { + use super::*; + + generate_download_install_tests!("asdf-test", "18.0.0"); + + #[tokio::test(flavor = "multi_thread")] + async fn downloads_tool_version() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + // Create plugin with download script + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/download", + "#!/bin/sh\necho 'Downloading nodejs 18.0.0' > $ASDF_DOWNLOAD_PATH/download.log", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/download"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + let result = plugin.download_prebuilt(DownloadPrebuiltInput { + context: ToolContext { + version: VersionSpec::parse("18.0.0").unwrap(), + ..Default::default() + }, + ..Default::default() + }).await; + assert!(!result.download_url.is_empty()); + + let download_log = fs::read_to_string( + sandbox.path().join("downloads/nodejs/18.0.0/download.log") + ).unwrap(); + assert_eq!(download_log.trim(), "Downloading nodejs 18.0.0"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn installs_tool_version() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + // Create plugin with install script + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/install", + "#!/bin/sh\necho 'Installing nodejs 18.0.0' > $ASDF_INSTALL_PATH/install.log", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/install"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + // Create download path + fs::create_dir_all(sandbox.path().join("downloads/nodejs/18.0.0")).unwrap(); + + let result = plugin.native_install(NativeInstallInput { + context: ToolContext { + version: VersionSpec::parse("18.0.0").unwrap(), + ..Default::default() + }, + ..Default::default() + }).await; + assert!(result.installed); + + let install_log = fs::read_to_string( + sandbox.path().join("installs/nodejs/18.0.0/install.log") + ).unwrap(); + assert_eq!(install_log.trim(), "Installing nodejs 18.0.0"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn handles_download_failure() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + // Create plugin with failing download script + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/download", + "#!/bin/sh\nexit 1", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/download"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + let result = plugin.download_prebuilt(DownloadPrebuiltInput { + context: ToolContext { + version: VersionSpec::parse("18.0.0").unwrap(), + ..Default::default() + }, + ..Default::default() + }).await; + assert!(result.download_url.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn handles_install_failure() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + // Create plugin with failing install script + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/install", + "#!/bin/sh\nexit 1", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/install"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + // Create download path + fs::create_dir_all(sandbox.path().join("downloads/nodejs/18.0.0")).unwrap(); + + let result = plugin.native_install(NativeInstallInput { + context: ToolContext { + version: VersionSpec::parse("18.0.0").unwrap(), + ..Default::default() + }, + ..Default::default() + }).await; + assert!(!result.installed); + } +} \ No newline at end of file diff --git a/tools/asdf/tests/metadata_test.rs b/tools/asdf/tests/metadata_test.rs new file mode 100644 index 0000000..60057cd --- /dev/null +++ b/tools/asdf/tests/metadata_test.rs @@ -0,0 +1,101 @@ +use proto_pdk_test_utils::*; +use std::fs; +use std::os::unix::fs::PermissionsExt; + +mod asdf_tool { + use super::*; + + generate_resolve_versions_tests!("asdf-test", { + "18.0.0" => "18.0.0", + "17.0.0" => "17.0.0", + "16.0.0" => "16.0.0", + }); + + #[tokio::test(flavor = "multi_thread")] + async fn loads_versions_from_plugin() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + let output = plugin.load_versions(LoadVersionsInput::default()).await; + assert!(!output.versions.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn sets_latest_alias() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + let output = plugin.load_versions(LoadVersionsInput::default()).await; + assert!(output.latest.is_some()); + assert!(output.aliases.contains_key("latest")); + assert_eq!(output.aliases.get("latest"), output.latest.as_ref()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn registers_tool() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf").await; + + let output = plugin.register_tool(ToolMetadataInput::default()).await; + assert_eq!(output.name, "asdf"); + assert_eq!(output.type_of, PluginType::Language); + assert!(output.minimum_proto_version.is_some()); + assert!(output.plugin_version.is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn lists_all_tool_versions() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf").await; + + // Create plugin with list-all script + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/list-all", + "#!/bin/sh\necho '18.0.0 17.0.0 16.0.0'", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/list-all"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + let output = plugin.load_versions(LoadVersionsInput::default()).await; + assert_eq!(output.versions.len(), 3); + assert!(output.versions.contains(&VersionSpec::parse("18.0.0").unwrap())); + assert!(output.versions.contains(&VersionSpec::parse("17.0.0").unwrap())); + assert!(output.versions.contains(&VersionSpec::parse("16.0.0").unwrap())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn gets_latest_stable_version() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf").await; + + // Create plugin with latest-stable script + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/latest-stable", + "#!/bin/sh\necho '18.0.0'", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/latest-stable"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + let output = plugin.load_versions(LoadVersionsInput::default()).await; + assert!(output.latest.is_some()); + assert_eq!(output.latest.unwrap(), VersionSpec::parse("18.0.0").unwrap()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn handles_missing_latest_stable() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf").await; + + // Create plugin without latest-stable script + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + + let output = plugin.load_versions(LoadVersionsInput::default()).await; + assert!(output.latest.is_none()); +} \ No newline at end of file diff --git a/tools/asdf/tests/versions_test.rs b/tools/asdf/tests/versions_test.rs new file mode 100644 index 0000000..33c196b --- /dev/null +++ b/tools/asdf/tests/versions_test.rs @@ -0,0 +1,153 @@ +use proto_pdk::*; +use proto_pdk_test_utils::*; +use std::fs; +use std::os::unix::fs::PermissionsExt; + +mod asdf_tool { + use super::*; + + generate_resolve_versions_tests!("asdf-test", { + "18.0.0" => "18.0.0", + "17.0.0" => "17.0.0", + "16.0.0" => "16.0.0", + }); + + #[tokio::test(flavor = "multi_thread")] + async fn loads_versions_from_scripts() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + // Create plugin with list-all script + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/list-all", + "#!/bin/sh\necho '18.0.0 17.0.0 16.0.0'", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/list-all"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + let output = plugin.load_versions(LoadVersionsInput::default()).await; + assert!(!output.versions.is_empty()); + assert!(output.versions.contains(&VersionSpec::parse("18.0.0").unwrap())); + assert!(output.versions.contains(&VersionSpec::parse("17.0.0").unwrap())); + assert!(output.versions.contains(&VersionSpec::parse("16.0.0").unwrap())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn sets_latest_alias() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + // Create plugin with latest-stable script + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/latest-stable", + "#!/bin/sh\necho '18.0.0'", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/latest-stable"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + let output = plugin.load_versions(LoadVersionsInput::default()).await; + assert!(output.latest.is_some()); + assert!(output.aliases.contains_key("latest")); + assert_eq!(output.aliases.get("latest"), output.latest.as_ref()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn detects_version_files() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + let output = plugin.detect_version_files().await; + assert_eq!(output.files, vec![".tool-versions"]); + } + + #[tokio::test(flavor = "multi_thread")] + async fn parses_version_file() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("asdf-test").await; + + let output = plugin.parse_version_file(ParseVersionFileInput { + file: ".tool-versions".into(), + content: "nodejs 18.0.0".into(), + ..Default::default() + }).await; + assert_eq!(output.version.unwrap().to_string(), "18.0.0"); + } +} + +#[tokio::test] +async fn detects_legacy_version_files() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("test").await; + + // Create plugin with legacy version files + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/list-legacy-filenames", + "#!/bin/sh\necho '.node-version .nvmrc'", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/list-legacy-filenames"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + // Create legacy version file + sandbox.create_file(".node-version", "18.0.0"); + + let result = plugin.detect_version_files().await; + assert!(result.files.contains(&".node-version".into())); + assert!(result.files.contains(&".nvmrc".into())); +} + +#[tokio::test] +async fn parses_tool_versions() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("test").await; + + // Create .tool-versions file + sandbox.create_file( + ".tool-versions", + "nodejs 18.0.0 16.0.0\nruby 3.2.0\n", + ); + + let version = plugin.parse_version_file(ParseVersionFileInput { + file: ".tool-versions".into(), + content: fs::read_to_string(sandbox.path().join(".tool-versions")).unwrap(), + ..Default::default() + }).await.version.unwrap(); + + assert_eq!(version.to_string(), "18.0.0"); +} + +#[tokio::test] +async fn parses_legacy_version_file() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("test").await; + + // Create plugin with legacy version parser + fs::create_dir_all(sandbox.path().join(".asdf/plugins/nodejs/bin")).unwrap(); + sandbox.create_file( + ".asdf/plugins/nodejs/bin/parse-legacy-file", + "#!/bin/sh\ncat $1", + ); + fs::set_permissions( + sandbox.path().join(".asdf/plugins/nodejs/bin/parse-legacy-file"), + fs::Permissions::from_mode(0o755), + ).unwrap(); + + // Create legacy version file + sandbox.create_file(".node-version", "18.0.0"); + + let version = plugin.parse_version_file(ParseVersionFileInput { + file: ".node-version".into(), + content: fs::read_to_string(sandbox.path().join(".node-version")).unwrap(), + ..Default::default() + }).await.version.unwrap(); + + assert_eq!(version.to_string(), "18.0.0"); +} \ No newline at end of file