diff --git a/.cursor/rules/development.mdc b/.cursor/rules/development.mdc index 032bc03480..c87b08f572 100644 --- a/.cursor/rules/development.mdc +++ b/.cursor/rules/development.mdc @@ -4,9 +4,9 @@ alwaysApply: true - `cargo build --all-features` - build the project - `target/debug/mise` - run the built binary -- `mise run test:e2e -- [test_filename]...` - run e2e tests +- `mise run test:e2e [test_filename]...` - run e2e tests - `mise run test:unit` - run unit tests - `mise run lint` - run linting - `mise run lint-fix` - run linting and fix issues -Don't run e2e tests by trying to execute them directly, always use `mise run test:e2e -- [test_filename]...` +Don't run e2e tests by trying to execute them directly, always use `mise run test:e2e [test_filename]...` diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 3c50bf3f5c..34334f646f 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -5,7 +5,7 @@ alwaysApply: false Testing and linting commands should be run via `mise run`. -- `mise run test:e2e -- [test_filename]...` executes an e2e test +- `mise run test:e2e [test_filename]...` executes an e2e test - `mise run test:unit` executes the unit tests - `mise run lint` runs the linting commands - `mise run lint-fix` runs the linting commands and fixes the issues diff --git a/docs/dev-tools/backends/github.md b/docs/dev-tools/backends/github.md index f097b040f4..30353f4640 100644 --- a/docs/dev-tools/backends/github.md +++ b/docs/dev-tools/backends/github.md @@ -38,13 +38,15 @@ Specifies the pattern to match against release asset names. This is useful when ### Platform-specific Asset Patterns -You can specify different asset patterns for different platforms: +For different asset patterns per platform: ```toml [tools."github:cli/cli"] version = "latest" -platforms_linux_x64_asset_pattern = "gh_*_linux_x64.tar.gz" -platforms_macos_arm64_asset_pattern = "gh_*_macOS_arm64.tar.gz" + +[tools."github:cli/cli".platforms] +linux-x64 = { asset_pattern = "gh_*_linux_x64.tar.gz" } +macos-arm64 = { asset_pattern = "gh_*_macOS_arm64.tar.gz" } ``` ### `checksum` @@ -62,13 +64,13 @@ checksum = "sha256:a1b2c3d4e5f6789..." ### Platform-specific Checksums -You can specify different checksums for different platforms: - ```toml [tools."github:cli/cli"] version = "latest" -platforms_linux_x64_checksum = "sha256:a1b2c3d4e5f6789..." -platforms_macos_arm64_checksum = "sha256:b2c3d4e5f6789..." + +[tools."github:cli/cli".platforms] +linux-x64 = { asset_pattern = "gh_*_linux_x64.tar.gz", checksum = "sha256:a1b2c3d4e5f6789..." } +macos-arm64 = { asset_pattern = "gh_*_macOS_arm64.tar.gz", checksum = "sha256:b2c3d4e5f6789..." } ``` ### `size` @@ -91,14 +93,12 @@ Number of directory components to strip when extracting archives: ### `bin_path` -Specify the directory containing binaries within the extracted archive: +Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports templating with `{name}`, `{version}`, `{os}`, `{arch}`, and `{ext}`: ```toml -[tools] -"github:cli/cli" = { - version = "latest", - bin_path = "bin" -} +[tools."github:cli/cli"] +version = "latest" +bin_path = "{name}-{version}/bin" # expands to cli-1.0.0/bin ``` **Binary path lookup order:** diff --git a/docs/dev-tools/backends/gitlab.md b/docs/dev-tools/backends/gitlab.md index fc80af15ee..e1e1131045 100644 --- a/docs/dev-tools/backends/gitlab.md +++ b/docs/dev-tools/backends/gitlab.md @@ -39,13 +39,15 @@ asset_pattern = "gitlab-runner-linux-x64" ### Platform-specific Asset Patterns -You can specify different asset patterns for different platforms: +For different asset patterns per platform: ```toml [tools."gitlab:gitlab-org/gitlab-runner"] version = "latest" -platforms_linux_x64_asset_pattern = "gitlab-runner-linux-x64" -platforms_macos_arm64_asset_pattern = "gitlab-runner-macos-arm64" + +[tools."gitlab:gitlab-org/gitlab-runner".platforms] +linux-x64 = { asset_pattern = "gitlab-runner-linux-x64" } +macos-arm64 = { asset_pattern = "gitlab-runner-macos-arm64" } ``` ### `checksum` @@ -63,13 +65,13 @@ checksum = "sha256:a1b2c3d4e5f6789..." ### Platform-specific Checksums -You can specify different checksums for different platforms: - ```toml [tools."gitlab:gitlab-org/gitlab-runner"] version = "latest" -platforms_linux_x64_checksum = "sha256:a1b2c3d4e5f6789..." -platforms_macos_arm64_checksum = "sha256:b2c3d4e5f6789..." + +[tools."gitlab:gitlab-org/gitlab-runner".platforms] +linux-x64 = { asset_pattern = "gitlab-runner-linux-x64", checksum = "sha256:a1b2c3d4e5f6789..." } +macos-arm64 = { asset_pattern = "gitlab-runner-macos-arm64", checksum = "sha256:b2c3d4e5f6789..." } ``` ### `size` @@ -88,8 +90,10 @@ You can specify different sizes for different platforms: ```toml [tools."gitlab:gitlab-org/gitlab-runner"] version = "latest" -platforms_linux_x64_size = "12345678" -platforms_macos_arm64_size = "9876543" + +[tools."gitlab:gitlab-org/gitlab-runner".platforms] +linux-x64 = { size = "12345678" } +macos-arm64 = { size = "9876543" } ``` ### `strip_components` @@ -103,11 +107,12 @@ Number of directory components to strip when extracting archives: ### `bin_path` -Specify the directory containing binaries within the extracted archive: +Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports templating with `{name}`, `{version}`, `{os}`, `{arch}`, and `{ext}`: ```toml -[tools] -"gitlab:gitlab-org/gitlab-runner" = { version = "latest", bin_path = "bin" } +[tools."gitlab:gitlab-org/gitlab-runner"] +version = "latest" +bin_path = "{name}-{version}/bin" # expands to gitlab-runner-1.0.0/bin ``` **Binary path lookup order:** diff --git a/docs/dev-tools/backends/http.md b/docs/dev-tools/backends/http.md index b84a1118cf..0a81573b6e 100644 --- a/docs/dev-tools/backends/http.md +++ b/docs/dev-tools/backends/http.md @@ -39,14 +39,16 @@ Specifies the HTTP URL to download the tool from: ### Platform-specific URLs -You can specify different URLs for different platforms: +For tools that need different downloads per platform, use the table format: ```toml [tools."http:my-tool"] version = "1.0.0" -platforms_macos_x64_url = "https://example.com/releases/my-tool-v1.0.0-macos-x64.tar.gz" -platforms_macos_arm64_url = "https://example.com/releases/my-tool-v1.0.0-macos-arm64.tar.gz" -platforms_linux_x64_url = "https://example.com/releases/my-tool-v1.0.0-linux-x64.tar.gz" + +[tools."http:my-tool".platforms] +macos-x64 = { url = "https://example.com/releases/my-tool-v1.0.0-macos-x64.tar.gz" } +macos-arm64 = { url = "https://example.com/releases/my-tool-v1.0.0-macos-arm64.tar.gz" } +linux-x64 = { url = "https://example.com/releases/my-tool-v1.0.0-linux-x64.tar.gz" } ``` > **Note:** You can use either `macos` or `darwin`, and `x64` or `amd64` for platform keys. `macos` and `x64` are preferred in documentation and examples, but all variants are accepted. @@ -66,17 +68,14 @@ checksum = "sha256:a1b2c3d4e5f6789..." ### Platform-specific Checksums -You can specify different checksums for different platforms: - ```toml [tools."http:my-tool"] version = "1.0.0" -platforms_macos_x64_url = "https://example.com/releases/my-tool-v1.0.0-macos-x64.tar.gz" -platforms_macos_x64_checksum = "sha256:a1b2c3d4e5f6789..." -platforms_macos_arm64_url = "https://example.com/releases/my-tool-v1.0.0-macos-arm64.tar.gz" -platforms_macos_arm64_checksum = "sha256:b2c3d4e5f6789..." -platforms_linux_x64_url = "https://example.com/releases/my-tool-v1.0.0-linux-x64.tar.gz" -platforms_linux_x64_checksum = "sha256:a1b2c3d4e5f6789..." + +[tools."http:my-tool".platforms] +macos-x64 = { url = "https://example.com/releases/my-tool-v1.0.0-macos-x64.tar.gz", checksum = "sha256:a1b2c3d4e5f6789..." } +macos-arm64 = { url = "https://example.com/releases/my-tool-v1.0.0-macos-arm64.tar.gz", checksum = "sha256:b2c3d4e5f6789..." } +linux-x64 = { url = "https://example.com/releases/my-tool-v1.0.0-linux-x64.tar.gz", checksum = "sha256:c3d4e5f6789..." } ``` ### `size` @@ -90,6 +89,20 @@ url = "https://example.com/releases/my-tool-v1.0.0.tar.gz" size = "12345678" ``` +### Platform-specific Size + +You can specify different sizes for different platforms: + +```toml +[tools."http:my-tool"] +version = "1.0.0" + +[tools."http:my-tool".platforms] +macos-x64 = { url = "https://example.com/releases/my-tool-v1.0.0-macos-x64.tar.gz", size = "12345678" } +macos-arm64 = { url = "https://example.com/releases/my-tool-v1.0.0-macos-arm64.tar.gz", size = "9876543" } +linux-x64 = { url = "https://example.com/releases/my-tool-v1.0.0-linux-x64.tar.gz", size = "11111111" } +``` + ### `strip_components` Number of directory components to strip when extracting archives: @@ -103,13 +116,13 @@ strip_components = 1 ### `bin_path` -Specify the directory containing binaries within the extracted archive, or where to place the downloaded file: +Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports templating with `{name}`, `{version}`, `{os}`, `{arch}`, and `{ext}`: ```toml [tools."http:my-tool"] version = "1.0.0" url = "https://example.com/releases/my-tool-v1.0.0.tar.gz" -bin_path = "bin" +bin_path = "{name}-{version}/bin" # expands to my-tool-1.0.0/bin ``` **Binary path lookup order:** diff --git a/docs/dev-tools/index.md b/docs/dev-tools/index.md index 41a2182550..34e8cdb213 100644 --- a/docs/dev-tools/index.md +++ b/docs/dev-tools/index.md @@ -94,6 +94,54 @@ mise supports nested configuration that cascades from broad to specific settings Each level can override or extend the previous ones, giving you fine-grained control over tool versions across different contexts. +## Tool Options + +Tool options allow you to customize how tools are installed and configured. They support nested configurations for better organization, particularly useful for platform-specific settings. + +### Table Format (Recommended) + +The cleanest way to specify nested options is using TOML tables: + +```toml +[tools."http:my-tool"] +version = "1.0.0" + +[tools."http:my-tool".platforms] +macos-x64 = { url = "https://example.com/my-tool-macos-x64.tar.gz", checksum = "sha256:abc123" } +linux-x64 = { url = "https://example.com/my-tool-linux-x64.tar.gz", checksum = "sha256:def456" } +``` + +### Dotted Notation + +You can also use dotted notation for simpler nested configurations: + +```toml +[tools."http:my-tool"] +version = "1.0.0" +platforms.macos-x64.url = "https://example.com/my-tool-macos-x64.tar.gz" +platforms.linux-x64.url = "https://example.com/my-tool-linux-x64.tar.gz" +simple_option = "value" +``` + +### Generic Nested Support + +Any backend can use nested options for organizing complex configurations: + +```toml +[tools."custom:my-backend"] +version = "1.0.0" + +[tools."custom:my-backend".database] +host = "localhost" +port = 5432 + +[tools."custom:my-backend".cache.redis] +host = "redis.example.com" +port = 6379 +``` + +Internally, nested options are flattened to dot notation (e.g., `platforms.macos-x64.url`, `database.host`, `cache.redis.port`) for backend access. + ### Caching and Performance mise uses intelligent caching to minimize overhead: @@ -208,38 +256,3 @@ alias mx="mise x --" Similarly, `mise run` can be used to [execute tasks](/tasks/) which will also activate the mise environment with all of your tools. - -## Tool Options - -mise plugins may accept configuration in the form of tool options specified in `mise.toml`: - -```toml -[tools] -# send arbitrary options to the plugin, passed as: -# MISE_TOOL_OPTS__FOO=bar -mytool = { version = '3.10', foo = 'bar' } -``` - -All tools can accept a `postinstall` option which is a shell command to run after the tool is installed: - -```toml -[tools] -node = { version = '20', postinstall = 'corepack enable' } -``` - -It's yet not possible to specify this via the CLI in `mise use`. As a workaround, you can use [mise config set](/cli/config/set.html): - -```shell -mise config set tools.node.version 20 -mise config set tools.node.postinstall 'corepack enable' -mise install -``` - -### `install_env` - -`install_env` is a special option that can be used to set environment variables during tool installation: - -```toml -[tools] -teleport-ent = { version = "11.3.11", install_env = { TELEPORT_ENT_ARCH = "amd64" } } -``` diff --git a/e2e/backend/test_http b/e2e/backend/test_http index bdc0925230..80fc37245e 100644 --- a/e2e/backend/test_http +++ b/e2e/backend/test_http @@ -66,13 +66,15 @@ assert_contains "mise x -- hello-world" "hello world" # Test HTTP backend with platform-specific URLs cat <mise.toml [tools."http:hello-platform"] -platform_darwin_arm64_url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" -platform_darwin_amd64_url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" -platform_linux_amd64_url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" version = "1.0.0" bin_path = "bin" postinstall = "chmod +x \$MISE_TOOL_INSTALL_PATH/bin/hello-world" strip_components = 1 + +[tools."http:hello-platform".platforms] +darwin-arm64 = { url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" } +darwin-amd64 = { url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" } +linux-amd64 = { url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" } EOF mise install @@ -104,3 +106,23 @@ assert_contains "cat mise.lock" 'version = "1.0.0"' assert_contains "cat mise.lock" 'backend = "http:hello-lock"' assert_contains "cat mise.lock" '[tools."http:hello-lock".checksums]' assert_contains "cat mise.lock" '"hello-world-1.0.0.tar.gz" = "blake3:71f774faa03daf1a58cc3339f8c73e6557348c8e0a2f3fb8148cc26e26bad83f"' + +# Test HTTP backend with URL templating +cat <mise.toml +[tools] +"http:hello-world" = { version = "1.0.0", url = "https://mise.jdx.dev/test-fixtures/{name}-{version}.tar.gz", bin_path = "hello-world-1.0.0/bin", postinstall = "chmod +x \$MISE_TOOL_INSTALL_PATH/hello-world-1.0.0/bin/hello-world" } +EOF + +mise uninstall --all && mise install +mise env +assert_contains "mise x -- hello-world" "hello world" + +# Test HTTP backend with templated bin_path +cat <mise.toml +[tools] +"http:hello-world" = { version = "1.0.0", url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz", bin_path = "{name}-{version}/bin", postinstall = "chmod +x \$MISE_TOOL_INSTALL_PATH/hello-world-1.0.0/bin/hello-world" } +EOF + +mise uninstall --all && mise install +mise env +assert_contains "mise x -- hello-world" "hello world" diff --git a/schema/mise.json b/schema/mise.json index 1adc355974..5a665cc563 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -1422,6 +1422,16 @@ "oneOf": [ { "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object", + "additionalProperties": true } ] } diff --git a/src/backend/github.rs b/src/backend/github.rs index 402c604d01..597d90be94 100644 --- a/src/backend/github.rs +++ b/src/backend/github.rs @@ -1,5 +1,8 @@ use crate::backend::backend_type::BackendType; -use crate::backend::platform::lookup_platform_key; +use crate::backend::static_helpers::lookup_platform_key; +use crate::backend::static_helpers::{ + get_filename_from_url, install_artifact, template_string, verify_artifact, +}; use crate::cli::args::BackendArg; use crate::config::Config; use crate::config::Settings; @@ -7,12 +10,11 @@ use crate::http::HTTP; use crate::install_context::InstallContext; use crate::toolset::ToolVersion; use crate::toolset::ToolVersionOptions; -use crate::{backend::Backend, file, github, gitlab, hash}; +use crate::{backend::Backend, github, gitlab}; use async_trait::async_trait; -use eyre::{Result, bail}; +use eyre::Result; use regex::Regex; use std::fmt::Debug; -use std::path::Path; use std::sync::Arc; #[derive(Debug)] @@ -77,18 +79,18 @@ impl Backend for UnifiedGitBackend { let asset_url = self.resolve_asset_url(&tv, &opts, repo, api_url).await?; // Download - let filename = self.get_filename_from_url(&asset_url)?; + let filename = get_filename_from_url(&asset_url); let file_path = tv.download_path().join(&filename); ctx.pr.set_message(format!("download {filename}")); HTTP.download_file(&asset_url, &file_path, Some(&ctx.pr)) .await?; - // Verify - self.verify_artifact(&tv, &file_path, &opts)?; + // Verify (shared) + verify_artifact(&tv, &file_path, &opts)?; - // Install - self.install_artifact(&tv, &file_path, &opts)?; + // Install (shared) + install_artifact(&tv, &file_path, &opts)?; // Verify checksum if specified self.verify_checksum(ctx, &mut tv, &file_path)?; @@ -102,7 +104,8 @@ impl Backend for UnifiedGitBackend { tv: &ToolVersion, ) -> Result> { let opts = tv.request.options(); - if let Some(bin_path) = opts.get("bin_path") { + if let Some(bin_path_template) = opts.get("bin_path") { + let bin_path = template_string(bin_path_template, tv); Ok(vec![tv.install_path().join(bin_path)]) } else { // Look for bin directory in the install path @@ -157,8 +160,8 @@ impl UnifiedGitBackend { }; // Check for direct platform-specific URLs first using the helper - if let Some(direct_url) = lookup_platform_key(&opts.opts, "url") { - return Ok(direct_url.clone()); + if let Some(direct_url) = lookup_platform_key(opts, "url") { + return Ok(direct_url); } if self.is_gitlab() { @@ -172,7 +175,7 @@ impl UnifiedGitBackend { async fn resolve_github_asset_url( &self, - _tv: &ToolVersion, + tv: &ToolVersion, opts: &ToolVersionOptions, repo: &str, api_url: &str, @@ -181,24 +184,28 @@ impl UnifiedGitBackend { let release = github::get_release_for_url(api_url, repo, version).await?; // Get platform-specific pattern first, then fall back to general pattern - let pattern = lookup_platform_key(&opts.opts, "asset_pattern") - .or_else(|| opts.get("asset_pattern")) - .map(|s| s.as_str()) - .unwrap_or("{name}-{version}-{target}.{ext}"); + let pattern = lookup_platform_key(opts, "asset_pattern") + .or_else(|| opts.get("asset_pattern").cloned()) + .unwrap_or("{name}-{version}-{os}-{arch}.{ext}".to_string()); + + // Template the pattern with actual values + let templated_pattern = template_string(&pattern, tv); // Find matching asset - pattern is already templated by mise.toml parsing let asset = release .assets .into_iter() - .find(|a| self.matches_pattern(&a.name, pattern)) - .ok_or_else(|| eyre::eyre!("No matching asset found for pattern: {}", pattern))?; + .find(|a| self.matches_pattern(&a.name, &templated_pattern)) + .ok_or_else(|| { + eyre::eyre!("No matching asset found for pattern: {}", templated_pattern) + })?; Ok(asset.browser_download_url) } async fn resolve_gitlab_asset_url( &self, - _tv: &ToolVersion, + tv: &ToolVersion, opts: &ToolVersionOptions, repo: &str, api_url: &str, @@ -207,18 +214,22 @@ impl UnifiedGitBackend { let release = gitlab::get_release_for_url(api_url, repo, version).await?; // Get platform-specific pattern first, then fall back to general pattern - let pattern = lookup_platform_key(&opts.opts, "asset_pattern") - .or_else(|| opts.get("asset_pattern")) - .map(|s| s.as_str()) - .unwrap_or("{name}-{version}-{os}-{arch}.{ext}"); + let pattern = lookup_platform_key(opts, "asset_pattern") + .or_else(|| opts.get("asset_pattern").cloned()) + .unwrap_or("{name}-{version}-{os}-{arch}.{ext}".to_string()); + + // Template the pattern with actual values + let templated_pattern = template_string(&pattern, tv); // Find matching asset - pattern is already templated by mise.toml parsing let asset = release .assets .links .into_iter() - .find(|a| self.matches_pattern(&a.name, pattern)) - .ok_or_else(|| eyre::eyre!("No matching asset found for pattern: {}", pattern))?; + .find(|a| self.matches_pattern(&a.name, &templated_pattern)) + .ok_or_else(|| { + eyre::eyre!("No matching asset found for pattern: {}", templated_pattern) + })?; Ok(asset.direct_asset_url) } @@ -237,92 +248,4 @@ impl UnifiedGitBackend { asset_name.contains(pattern) } } - - fn get_filename_from_url(&self, url: &str) -> Result { - Ok(url.split('/').next_back().unwrap_or("download").to_string()) - } - - fn verify_artifact( - &self, - _tv: &ToolVersion, - file_path: &Path, - opts: &ToolVersionOptions, - ) -> Result<()> { - // Check platform-specific checksum first - let checksum = lookup_platform_key(&opts.opts, "checksum").or_else(|| opts.get("checksum")); - - if let Some(checksum) = checksum { - self.verify_checksum_str(file_path, checksum)?; - } - - // Check platform-specific size - let size = lookup_platform_key(&opts.opts, "size").or_else(|| opts.get("size")); - - if let Some(size_str) = size { - let expected_size: u64 = size_str.parse()?; - let actual_size = file_path.metadata()?.len(); - if actual_size != expected_size { - bail!( - "Size mismatch: expected {}, got {}", - expected_size, - actual_size - ); - } - } - - Ok(()) - } - - fn verify_checksum_str(&self, file_path: &Path, checksum: &str) -> Result<()> { - if let Some((algo, hash_str)) = checksum.split_once(':') { - hash::ensure_checksum(file_path, hash_str, None, algo)?; - } else { - bail!("Invalid checksum format: {}", checksum); - } - Ok(()) - } - - fn install_artifact( - &self, - tv: &ToolVersion, - file_path: &Path, - opts: &ToolVersionOptions, - ) -> Result<()> { - let install_path = tv.install_path(); - let strip_components = opts - .get("strip_components") - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - - file::remove_all(&install_path)?; - file::create_dir_all(&install_path)?; - - // Use TarFormat for format detection - let ext = file_path.extension().and_then(|s| s.to_str()).unwrap_or(""); - let format = file::TarFormat::from_ext(ext); - let tar_opts = file::TarOptions { - format, - strip_components, - pr: None, - }; - if format == file::TarFormat::Zip { - file::unzip(file_path, &install_path)?; - } else if format == file::TarFormat::Raw { - // Copy the file directly to the bin_path directory or install_path - if let Some(bin_path) = opts.get("bin_path") { - let bin_dir = install_path.join(bin_path); - file::create_dir_all(&bin_dir)?; - let dest = bin_dir.join(file_path.file_name().unwrap()); - file::copy(file_path, &dest)?; - file::make_executable(&dest)?; - } else { - let dest = install_path.join(file_path.file_name().unwrap()); - file::copy(file_path, &dest)?; - file::make_executable(&dest)?; - } - } else { - file::untar(file_path, &install_path, &tar_opts)?; - } - Ok(()) - } } diff --git a/src/backend/http.rs b/src/backend/http.rs index e4a684d20a..7cf6d525ec 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -1,17 +1,17 @@ +use crate::backend::Backend; use crate::backend::backend_type::BackendType; -use crate::backend::platform::lookup_platform_key; +use crate::backend::static_helpers::{ + get_filename_from_url, install_artifact, lookup_platform_key, template_string, verify_artifact, +}; use crate::cli::args::BackendArg; use crate::config::Config; use crate::config::Settings; use crate::http::HTTP; use crate::install_context::InstallContext; use crate::toolset::ToolVersion; -use crate::toolset::ToolVersionOptions; -use crate::{backend::Backend, file, hash}; use async_trait::async_trait; -use eyre::{Result, bail}; +use eyre::Result; use std::fmt::Debug; -use std::path::Path; use std::sync::Arc; #[derive(Debug)] @@ -43,23 +43,25 @@ impl Backend for HttpBackend { let opts = tv.request.options(); // Use the new helper to get platform-specific URL first, then fall back to general URL - let url = lookup_platform_key(&opts.opts, "url") - .cloned() + let url_template = lookup_platform_key(&opts, "url") .or_else(|| opts.get("url").cloned()) .ok_or_else(|| eyre::eyre!("Http backend requires 'url' option"))?; + // Template the URL with actual values + let url = template_string(&url_template, &tv); + // Download - let filename = self.get_filename_from_url(&url)?; + let filename = get_filename_from_url(&url); let file_path = tv.download_path().join(&filename); ctx.pr.set_message(format!("download {filename}")); HTTP.download_file(&url, &file_path, Some(&ctx.pr)).await?; - // Verify - self.verify_artifact(&tv, &file_path, &opts)?; + // Verify (shared) + verify_artifact(&tv, &file_path, &opts)?; - // Install - self.install_artifact(&tv, &file_path, &opts)?; + // Install (shared) + install_artifact(&tv, &file_path, &opts)?; // Verify checksum if specified self.verify_checksum(ctx, &mut tv, &file_path)?; @@ -73,8 +75,8 @@ impl Backend for HttpBackend { tv: &ToolVersion, ) -> Result> { let opts = tv.request.options(); - if let Some(bin_path) = opts.get("bin_path") { - // Always treat bin_path as a directory + if let Some(bin_path_template) = opts.get("bin_path") { + let bin_path = template_string(bin_path_template, tv); Ok(vec![tv.install_path().join(bin_path)]) } else { // Look for bin directory in the install path @@ -106,88 +108,4 @@ impl HttpBackend { pub fn from_arg(ba: BackendArg) -> Self { Self { ba: Arc::new(ba) } } - - fn get_filename_from_url(&self, url: &str) -> Result { - Ok(url.split('/').next_back().unwrap_or("download").to_string()) - } - - fn verify_artifact( - &self, - _tv: &ToolVersion, - file_path: &Path, - opts: &ToolVersionOptions, - ) -> Result<()> { - // Check checksum if specified - if let Some(checksum) = opts.get("checksum") { - self.verify_checksum_str(file_path, checksum)?; - } - - // Check size if specified - if let Some(size_str) = opts.get("size") { - let expected_size: u64 = size_str.parse()?; - let actual_size = file_path.metadata()?.len(); - if actual_size != expected_size { - bail!( - "Size mismatch: expected {}, got {}", - expected_size, - actual_size - ); - } - } - - Ok(()) - } - - fn verify_checksum_str(&self, file_path: &Path, checksum: &str) -> Result<()> { - if let Some((algo, hash_str)) = checksum.split_once(':') { - hash::ensure_checksum(file_path, hash_str, None, algo)?; - } else { - bail!("Invalid checksum format: {}", checksum); - } - Ok(()) - } - - fn install_artifact( - &self, - tv: &ToolVersion, - file_path: &Path, - opts: &ToolVersionOptions, - ) -> Result<()> { - let install_path = tv.install_path(); - let strip_components = opts - .get("strip_components") - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - - file::remove_all(&install_path)?; - file::create_dir_all(&install_path)?; - - // Use TarFormat for format detection - let ext = file_path.extension().and_then(|s| s.to_str()).unwrap_or(""); - let format = file::TarFormat::from_ext(ext); - let tar_opts = file::TarOptions { - format, - strip_components, - pr: None, - }; - if format == file::TarFormat::Zip { - file::unzip(file_path, &install_path)?; - } else if format == file::TarFormat::Raw { - // Copy the file directly to the bin_path directory or install_path - if let Some(bin_path) = opts.get("bin_path") { - let bin_dir = install_path.join(bin_path); - file::create_dir_all(&bin_dir)?; - let dest = bin_dir.join(file_path.file_name().unwrap()); - file::copy(file_path, &dest)?; - file::make_executable(&dest)?; - } else { - let dest = install_path.join(file_path.file_name().unwrap()); - file::copy(file_path, &dest)?; - file::make_executable(&dest)?; - } - } else { - file::untar(file_path, &install_path, &tar_opts)?; - } - Ok(()) - } } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 3bfc64431d..568539f749 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -46,8 +46,8 @@ pub mod go; pub mod http; pub mod npm; pub mod pipx; -pub mod platform; pub mod spm; +pub mod static_helpers; pub mod ubi; pub mod vfox; diff --git a/src/backend/platform.rs b/src/backend/platform.rs deleted file mode 100644 index a56a130b88..0000000000 --- a/src/backend/platform.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::collections::BTreeMap; - -/// Returns all possible aliases for the current platform (os, arch), -/// with the preferred spelling first (macos/x64, linux/x64, etc). -pub fn platform_aliases() -> Vec<(String, String)> { - let os = std::env::consts::OS; - let arch = std::env::consts::ARCH; - let mut aliases = vec![]; - - // OS aliases - let os_aliases = match os { - "macos" | "darwin" => vec!["macos", "darwin"], - "linux" => vec!["linux"], - "windows" => vec!["windows"], - _ => vec![os], - }; - - // Arch aliases - let arch_aliases = match arch { - "x86_64" | "amd64" => vec!["x64", "amd64", "x86_64"], - "aarch64" | "arm64" => vec!["arm64", "aarch64"], - _ => vec![arch], - }; - - for os in &os_aliases { - for arch in &arch_aliases { - aliases.push((os.to_string(), arch.to_string())); - } - } - aliases -} - -/// Looks up a value in a BTreeMap using all possible platform key aliases. -/// Example: for key_type = "url", will check platform_macos_x64_url, platform_darwin_amd64_url, etc. -/// Also supports both "platforms_" and "platform_" prefixes. -pub fn lookup_platform_key<'a>( - opts: &'a BTreeMap, - key_type: &str, -) -> Option<&'a String> { - for (os, arch) in platform_aliases() { - for prefix in ["platforms", "platform"] { - if let Some(val) = opts.get(&format!("{prefix}_{os}_{arch}_{key_type}")) { - return Some(val); - } - } - } - None -} diff --git a/src/backend/static_helpers.rs b/src/backend/static_helpers.rs new file mode 100644 index 0000000000..e3508ab19c --- /dev/null +++ b/src/backend/static_helpers.rs @@ -0,0 +1,250 @@ +// Shared template logic for backends +use crate::config::Settings; +use crate::file; +use crate::hash; +use crate::toolset::ToolVersion; +use crate::toolset::ToolVersionOptions; +use eyre::{Result, bail}; +use std::path::Path; + +/// Returns all possible aliases for the current platform (os, arch), +/// with the preferred spelling first (macos/x64, linux/x64, etc). +pub fn platform_aliases() -> Vec<(String, String)> { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + let mut aliases = vec![]; + + // OS aliases + let os_aliases = match os { + "macos" | "darwin" => vec!["macos", "darwin"], + "linux" => vec!["linux"], + "windows" => vec!["windows"], + _ => vec![os], + }; + + // Arch aliases + let arch_aliases = match arch { + "x86_64" | "amd64" => vec!["x64", "amd64", "x86_64"], + "aarch64" | "arm64" => vec!["arm64", "aarch64"], + _ => vec![arch], + }; + + for os in &os_aliases { + for arch in &arch_aliases { + aliases.push((os.to_string(), arch.to_string())); + } + } + aliases +} + +/// Looks up a value in ToolVersionOptions using nested platform key format. +/// Supports nested format (platforms.macos-x64.url) with os-arch dash notation. +/// Also supports both "platforms" and "platform" prefixes. +pub fn lookup_platform_key(opts: &ToolVersionOptions, key_type: &str) -> Option { + // Try nested platform structure with os-arch format + for (os, arch) in platform_aliases() { + for prefix in ["platforms", "platform"] { + // Try nested format: platforms.macos-x64.url + let nested_key = format!("{prefix}.{os}-{arch}.{key_type}"); + if let Some(val) = opts.get_nested_string(&nested_key) { + return Some(val); + } + // Try flat format: platforms_macos_arm64_url + let flat_key = format!("{prefix}_{os}_{arch}_{key_type}"); + if let Some(val) = opts.get(&flat_key) { + return Some(val.clone()); + } + } + } + None +} + +pub fn template_string(template: &str, tv: &ToolVersion) -> String { + let name = tv.ba().tool_name(); + let version = &tv.version; + let settings = Settings::get(); + let os = settings.os(); + let arch = settings.arch(); + let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; + + template + .replace("{name}", &name) + .replace("{version}", version) + .replace("{os}", os) + .replace("{arch}", arch) + .replace("{ext}", ext) +} + +pub fn get_filename_from_url(url: &str) -> String { + url.split('/').next_back().unwrap_or("download").to_string() +} + +pub fn install_artifact( + tv: &crate::toolset::ToolVersion, + file_path: &Path, + opts: &ToolVersionOptions, +) -> eyre::Result<()> { + let install_path = tv.install_path(); + let strip_components = opts + .get("strip_components") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + file::remove_all(&install_path)?; + file::create_dir_all(&install_path)?; + + // Use TarFormat for format detection + let ext = file_path.extension().and_then(|s| s.to_str()).unwrap_or(""); + let format = file::TarFormat::from_ext(ext); + let tar_opts = file::TarOptions { + format, + strip_components, + pr: None, + }; + if format == file::TarFormat::Zip { + file::unzip(file_path, &install_path)?; + } else if format == file::TarFormat::Raw { + // Copy the file directly to the bin_path directory or install_path + if let Some(bin_path_template) = opts.get("bin_path") { + let bin_path = template_string(bin_path_template, tv); + let bin_dir = install_path.join(bin_path); + file::create_dir_all(&bin_dir)?; + let dest = bin_dir.join(file_path.file_name().unwrap()); + file::copy(file_path, &dest)?; + file::make_executable(&dest)?; + } else { + let dest = install_path.join(file_path.file_name().unwrap()); + file::copy(file_path, &dest)?; + file::make_executable(&dest)?; + } + } else { + file::untar(file_path, &install_path, &tar_opts)?; + } + Ok(()) +} + +pub fn verify_artifact( + _tv: &crate::toolset::ToolVersion, + file_path: &Path, + opts: &crate::toolset::ToolVersionOptions, +) -> Result<()> { + // Check platform-specific checksum first, then fall back to generic + let checksum = lookup_platform_key(opts, "checksum").or_else(|| opts.get("checksum").cloned()); + + if let Some(checksum) = checksum { + verify_checksum_str(file_path, &checksum)?; + } + + // Check platform-specific size first, then fall back to generic + let size_str = lookup_platform_key(opts, "size").or_else(|| opts.get("size").cloned()); + + if let Some(size_str) = size_str { + let expected_size: u64 = size_str.parse()?; + let actual_size = file_path.metadata()?.len(); + if actual_size != expected_size { + bail!( + "Size mismatch: expected {}, got {}", + expected_size, + actual_size + ); + } + } + + Ok(()) +} + +pub fn verify_checksum_str(file_path: &Path, checksum: &str) -> Result<()> { + if let Some((algo, hash_str)) = checksum.split_once(':') { + hash::ensure_checksum(file_path, hash_str, None, algo)?; + } else { + bail!("Invalid checksum format: {}", checksum); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::toolset::ToolVersionOptions; + use indexmap::IndexMap; + + #[test] + fn test_verify_artifact_platform_specific() { + let mut opts = IndexMap::new(); + opts.insert( + "platforms".to_string(), + r#" +[macos-x64] +checksum = "blake3:abc123" +size = "1024" + +[macos-arm64] +checksum = "blake3:jkl012" +size = "4096" + +[linux-x64] +checksum = "blake3:def456" +size = "2048" + +[linux-arm64] +checksum = "blake3:mno345" +size = "5120" + +[windows-x64] +checksum = "blake3:ghi789" +size = "3072" + +[windows-arm64] +checksum = "blake3:mno345" +size = "5120" +"# + .to_string(), + ); + + let tool_opts = ToolVersionOptions { + opts, + ..Default::default() + }; + + // Test that platform-specific checksum and size are found + // This test verifies that lookup_platform_key is being used correctly + // The actual verification would require a real file, but we can test the lookup logic + let checksum = lookup_platform_key(&tool_opts, "checksum"); + let size = lookup_platform_key(&tool_opts, "size"); + + // Skip the test if the current platform isn't supported in the test data + if checksum.is_none() || size.is_none() { + eprintln!( + "Skipping test_verify_artifact_platform_specific: current platform not supported in test data" + ); + return; + } + + // The exact values depend on the current platform, but we should get some value + // If we're not on a supported platform, the test should still pass + // since the function should handle missing platform-specific values gracefully + assert!(checksum.is_some()); + assert!(size.is_some()); + } + + #[test] + fn test_verify_artifact_fallback_to_generic() { + let mut opts = IndexMap::new(); + opts.insert("checksum".to_string(), "blake3:generic123".to_string()); + opts.insert("size".to_string(), "512".to_string()); + + let tool_opts = ToolVersionOptions { + opts, + ..Default::default() + }; + + // Test that generic fallback works when no platform-specific values exist + let checksum = lookup_platform_key(&tool_opts, "checksum") + .or_else(|| tool_opts.get("checksum").cloned()); + let size = + lookup_platform_key(&tool_opts, "size").or_else(|| tool_opts.get("size").cloned()); + + assert_eq!(checksum, Some("blake3:generic123".to_string())); + assert_eq!(size, Some("512".to_string())); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 342f78c775..b7ad78348a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -188,7 +188,7 @@ pub struct CliGlobalOutputFlags { #[strum(serialize_all = "kebab-case")] pub enum Commands { Activate(activate::Activate), - Alias(alias::Alias), + Alias(Box), Asdf(asdf::Asdf), Backends(backends::Backends), BinPaths(bin_paths::BinPaths), diff --git a/src/cli/where.rs b/src/cli/where.rs index 0bcbd7ed43..789d98cc41 100644 --- a/src/cli/where.rs +++ b/src/cli/where.rs @@ -2,7 +2,7 @@ use eyre::Result; use crate::cli::args::ToolArg; use crate::config::Config; -use crate::errors::Error::VersionNotInstalled; +use crate::errors::Error; use crate::toolset::ToolsetBuilder; /// Display the installation path for a tool @@ -49,7 +49,10 @@ impl Where { miseprintln!("{}", tv.install_path().to_string_lossy()); Ok(()) } else { - Err(VersionNotInstalled(tv.ba().clone(), tv.version))? + Err(Error::VersionNotInstalled( + Box::new(tv.ba().clone()), + tv.version, + ))? } } } diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 9e34c2dd86..2608530af0 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -1151,20 +1151,30 @@ impl<'de> de::Deserialize<'de> for MiseTomlToolList { return Err(de::Error::custom("env must be a table")); } }, - _ => match v { - toml::Value::Boolean(v) => { - options.opts.insert(k, v.to_string()); - } - toml::Value::Integer(v) => { - options.opts.insert(k, v.to_string()); - } - toml::Value::String(v) => { - options.opts.insert(k, v); - } - _ => { - return Err(de::Error::custom("invalid value type")); + _ => { + // Handle nested structures + match v { + toml::Value::Table(_) => { + // Store as TOML string, will be flattened later + options.opts.insert(k, v.to_string()); + } + toml::Value::String(s) => { + options.opts.insert(k, s); + } + toml::Value::Boolean(b) => { + options.opts.insert(k, b.to_string()); + } + toml::Value::Integer(i) => { + options.opts.insert(k, i.to_string()); + } + toml::Value::Float(f) => { + options.opts.insert(k, f.to_string()); + } + _ => { + return Err(de::Error::custom("invalid value type")); + } } - }, + } } } if let Some(tt) = tt { diff --git a/src/env.rs b/src/env.rs index 5f8e0176df..b7295476b5 100644 --- a/src/env.rs +++ b/src/env.rs @@ -561,6 +561,11 @@ mod tests { #[test] fn test_token_overwrite() { + // Clean up any existing environment variables that might interfere + remove_var("MISE_GITHUB_TOKEN"); + remove_var("GITHUB_TOKEN"); + remove_var("GITHUB_API_TOKEN"); + set_var("MISE_GITHUB_TOKEN", ""); set_var("GITHUB_TOKEN", "invalid_token"); assert_eq!( @@ -575,5 +580,6 @@ mod tests { ); remove_var("MISE_GITHUB_TOKEN"); remove_var("GITHUB_TOKEN"); + remove_var("GITHUB_API_TOKEN"); } } diff --git a/src/errors.rs b/src/errors.rs index 203e9018a1..6bdf18cc73 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -18,7 +18,7 @@ pub enum Error { #[error("[{0}] plugin not installed")] PluginNotInstalled(String), #[error("{0}@{1} not installed")] - VersionNotInstalled(BackendArg, String), + VersionNotInstalled(Box, String), #[error("{} exited with non-zero status: {}", .0, render_exit_status(.1))] ScriptFailed(String, Option), #[error( diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index 38120e1f07..443adfb246 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use std::fmt::{Display, Formatter}; use std::path::PathBuf; use std::sync::Arc; @@ -41,48 +41,9 @@ mod tool_request_set; mod tool_source; mod tool_version; mod tool_version_list; +mod tool_version_options; -#[derive(Debug, Default, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)] -pub struct ToolVersionOptions { - pub os: Option>, - pub install_env: BTreeMap, - #[serde(flatten)] - pub opts: BTreeMap, -} - -impl ToolVersionOptions { - pub fn is_empty(&self) -> bool { - self.install_env.is_empty() && self.opts.is_empty() - } - - pub fn get(&self, key: &str) -> Option<&String> { - self.opts.get(key) - } - - pub fn merge(&mut self, other: &BTreeMap) { - for (key, value) in other { - self.opts - .entry(key.to_string()) - .or_insert(value.to_string()); - } - } - - pub fn contains_key(&self, key: &str) -> bool { - self.opts.contains_key(key) - } -} - -pub fn parse_tool_options(s: &str) -> ToolVersionOptions { - let mut tvo = ToolVersionOptions::default(); - for opt in s.split(',') { - let (k, v) = opt.split_once('=').unwrap_or((opt, "")); - if k.is_empty() { - continue; - } - tvo.opts.insert(k.to_string(), v.to_string()); - } - tvo -} +pub use tool_version_options::{ToolVersionOptions, parse_tool_options}; #[derive(Debug, Clone)] pub struct InstallOptions { @@ -878,42 +839,3 @@ fn get_leaf_dependencies(requests: &[ToolRequest]) -> eyre::Result, ToolVersion); - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - use test_log::test; - - use super::ToolVersionOptions; - #[test] - fn test_tool_version_options() { - let t = |input, f| { - let opts = super::parse_tool_options(input); - assert_eq!(opts, f); - }; - t("", ToolVersionOptions::default()); - t( - "exe=rg", - ToolVersionOptions { - opts: [("exe".to_string(), "rg".to_string())] - .iter() - .cloned() - .collect(), - ..Default::default() - }, - ); - t( - "exe=rg,match=musl", - ToolVersionOptions { - opts: [ - ("exe".to_string(), "rg".to_string()), - ("match".to_string(), "musl".to_string()), - ] - .iter() - .cloned() - .collect(), - ..Default::default() - }, - ); - } -} diff --git a/src/toolset/tool_request_set.rs b/src/toolset/tool_request_set.rs index d74d0bca67..d2b6804714 100644 --- a/src/toolset/tool_request_set.rs +++ b/src/toolset/tool_request_set.rs @@ -89,7 +89,7 @@ impl ToolRequestSet { } } self.iter() - .filter(|(ba, ..)| tools.contains(&ba.short)) + .filter(|(ba, ..)| tools.contains(&ba.short) || tools.contains(&ba.full())) .map(|(ba, trl, ts)| (ba.clone(), trl.clone(), ts.clone())) .collect::() } diff --git a/src/toolset/tool_version_options.rs b/src/toolset/tool_version_options.rs new file mode 100644 index 0000000000..d38827f20e --- /dev/null +++ b/src/toolset/tool_version_options.rs @@ -0,0 +1,411 @@ +use indexmap::IndexMap; + +#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct ToolVersionOptions { + pub os: Option>, + pub install_env: IndexMap, + #[serde(flatten)] + pub opts: IndexMap, +} + +// Implement Hash manually to ensure deterministic hashing across IndexMap +impl std::hash::Hash for ToolVersionOptions { + fn hash(&self, state: &mut H) { + self.os.hash(state); + + // Hash install_env in sorted order for deterministic hashing + let mut install_env_sorted: Vec<_> = self.install_env.iter().collect(); + install_env_sorted.sort_by_key(|(k, _)| *k); + install_env_sorted.hash(state); + + // Hash opts in sorted order for deterministic hashing + let mut opts_sorted: Vec<_> = self.opts.iter().collect(); + opts_sorted.sort_by_key(|(k, _)| *k); + opts_sorted.hash(state); + } +} + +impl ToolVersionOptions { + pub fn is_empty(&self) -> bool { + self.install_env.is_empty() && self.opts.is_empty() + } + + pub fn get(&self, key: &str) -> Option<&String> { + // First try direct lookup + if let Some(value) = self.opts.get(key) { + return Some(value); + } + + // We can't return references to temporarily parsed TOML values, + // so nested lookup is not possible with this API. + // For nested values, users should access the raw opts and parse themselves. + None + } + + pub fn merge(&mut self, other: &IndexMap) { + for (key, value) in other { + self.opts + .entry(key.to_string()) + .or_insert(value.to_string()); + } + } + + pub fn contains_key(&self, key: &str) -> bool { + if self.opts.contains_key(key) { + return true; + } + + // Check if it's a nested key that exists + self.get_nested_value_exists(key) + } + + // Check if a nested value exists without returning a reference + fn get_nested_value_exists(&self, key: &str) -> bool { + // Split the key by dots to navigate nested structure + let parts: Vec<&str> = key.split('.').collect(); + if parts.len() < 2 { + return false; + } + + let root_key = parts[0]; + let nested_path = &parts[1..]; + + // Get the root value and try to parse it as TOML + if let Some(value) = self.opts.get(root_key) { + if let Ok(toml_value) = value.parse::() { + return Self::value_exists_at_path(&toml_value, nested_path); + } else if value.trim().starts_with('{') && value.trim().ends_with('}') { + // Try to parse as inline TOML table + if let Ok(toml_value) = format!("value = {value}").parse::() { + if let Some(table_value) = toml_value.get("value") { + return Self::value_exists_at_path(table_value, nested_path); + } + } + } + } + + false + } + + fn value_exists_at_path(value: &toml::Value, path: &[&str]) -> bool { + if path.is_empty() { + return matches!(value, toml::Value::String(_)); + } + + match value { + toml::Value::Table(table) => { + if let Some(next_value) = table.get(path[0]) { + Self::value_exists_at_path(next_value, &path[1..]) + } else { + false + } + } + _ => false, + } + } + + // New method to get nested values as owned Strings + pub fn get_nested_string(&self, key: &str) -> Option { + // Split the key by dots to navigate nested structure + let parts: Vec<&str> = key.split('.').collect(); + if parts.len() < 2 { + return None; + } + + let root_key = parts[0]; + let nested_path = &parts[1..]; + + // Get the root value and try to parse it as TOML + if let Some(value) = self.opts.get(root_key) { + if let Ok(toml_value) = value.parse::() { + return Self::get_string_at_path(&toml_value, nested_path); + } else if value.trim().starts_with('{') && value.trim().ends_with('}') { + // Try to parse as inline TOML table + if let Ok(toml_value) = format!("value = {value}").parse::() { + if let Some(table_value) = toml_value.get("value") { + return Self::get_string_at_path(table_value, nested_path); + } + } + } + } + + None + } + + fn get_string_at_path(value: &toml::Value, path: &[&str]) -> Option { + if path.is_empty() { + return match value { + toml::Value::String(s) => Some(s.clone()), + toml::Value::Integer(i) => Some(i.to_string()), + toml::Value::Boolean(b) => Some(b.to_string()), + toml::Value::Float(f) => Some(f.to_string()), + _ => None, + }; + } + + match value { + toml::Value::Table(table) => { + if let Some(next_value) = table.get(path[0]) { + Self::get_string_at_path(next_value, &path[1..]) + } else { + None + } + } + _ => None, + } + } +} + +pub fn parse_tool_options(s: &str) -> ToolVersionOptions { + let mut tvo = ToolVersionOptions::default(); + for opt in s.split(',') { + let (k, v) = opt.split_once('=').unwrap_or((opt, "")); + if k.is_empty() { + continue; + } + tvo.opts.insert(k.to_string(), v.to_string()); + } + tvo +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use test_log::test; + + #[test] + fn test_parse_tool_options() { + let t = |input, expected| { + let opts = parse_tool_options(input); + assert_eq!(opts, expected); + }; + + t("", ToolVersionOptions::default()); + t( + "exe=rg", + ToolVersionOptions { + opts: [("exe".to_string(), "rg".to_string())] + .iter() + .cloned() + .collect(), + ..Default::default() + }, + ); + t( + "exe=rg,match=musl", + ToolVersionOptions { + opts: [ + ("exe".to_string(), "rg".to_string()), + ("match".to_string(), "musl".to_string()), + ] + .iter() + .cloned() + .collect(), + ..Default::default() + }, + ); + } + + #[test] + fn test_nested_option_with_os_arch_dash() { + let mut opts = IndexMap::new(); + opts.insert( + "platforms".to_string(), + r#" +[macos-x64] +url = "https://example.com/macos-x64.tar.gz" +checksum = "sha256:abc123" + +[linux-x64] +url = "https://example.com/linux-x64.tar.gz" +checksum = "sha256:def456" +"# + .to_string(), + ); + + let tool_opts = ToolVersionOptions { + opts, + ..Default::default() + }; + + assert_eq!( + tool_opts.get_nested_string("platforms.macos-x64.url"), + Some("https://example.com/macos-x64.tar.gz".to_string()) + ); + assert_eq!( + tool_opts.get_nested_string("platforms.macos-x64.checksum"), + Some("sha256:abc123".to_string()) + ); + assert_eq!( + tool_opts.get_nested_string("platforms.linux-x64.url"), + Some("https://example.com/linux-x64.tar.gz".to_string()) + ); + assert_eq!( + tool_opts.get_nested_string("platforms.linux-x64.checksum"), + Some("sha256:def456".to_string()) + ); + } + + #[test] + fn test_generic_nested_options() { + let mut opts = IndexMap::new(); + opts.insert( + "config".to_string(), + r#" +[database] +host = "localhost" +port = 5432 + +[cache.redis] +host = "redis.example.com" +port = 6379 +"# + .to_string(), + ); + + let tool_opts = ToolVersionOptions { + opts, + ..Default::default() + }; + + assert_eq!( + tool_opts.get_nested_string("config.database.host"), + Some("localhost".to_string()) + ); + assert_eq!( + tool_opts.get_nested_string("config.database.port"), + Some("5432".to_string()) + ); + assert_eq!( + tool_opts.get_nested_string("config.cache.redis.host"), + Some("redis.example.com".to_string()) + ); + assert_eq!( + tool_opts.get_nested_string("config.cache.redis.port"), + Some("6379".to_string()) + ); + } + + #[test] + fn test_direct_and_nested_options() { + let mut opts = IndexMap::new(); + opts.insert( + "platforms".to_string(), + r#" +[macos-x64] +url = "https://example.com/macos-x64.tar.gz" +"# + .to_string(), + ); + opts.insert("simple_option".to_string(), "value".to_string()); + + let tool_opts = ToolVersionOptions { + opts, + ..Default::default() + }; + + // Test nested option + assert_eq!( + tool_opts.get_nested_string("platforms.macos-x64.url"), + Some("https://example.com/macos-x64.tar.gz".to_string()) + ); + // Test direct option + assert_eq!(tool_opts.get("simple_option"), Some(&"value".to_string())); + } + + #[test] + fn test_contains_key_with_nested_options() { + let mut opts = IndexMap::new(); + opts.insert( + "platforms".to_string(), + r#" +[macos-x64] +url = "https://example.com/macos-x64.tar.gz" +"# + .to_string(), + ); + + let tool_opts = ToolVersionOptions { + opts, + ..Default::default() + }; + + assert!(tool_opts.contains_key("platforms.macos-x64.url")); + assert!(!tool_opts.contains_key("platforms.linux-x64.url")); + assert!(!tool_opts.contains_key("nonexistent")); + } + + #[test] + fn test_merge_functionality() { + let mut opts = IndexMap::new(); + opts.insert( + "platforms".to_string(), + r#" +[macos-x64] +url = "https://example.com/macos-x64.tar.gz" +"# + .to_string(), + ); + + let mut tool_opts = ToolVersionOptions { + opts, + ..Default::default() + }; + + // Verify nested option access + assert!(tool_opts.contains_key("platforms.macos-x64.url")); + + // Merge new options + let mut new_opts = IndexMap::new(); + new_opts.insert("simple_option".to_string(), "value".to_string()); + tool_opts.merge(&new_opts); + + // Should be able to access both old and new options + assert!(tool_opts.contains_key("platforms.macos-x64.url")); + assert!(tool_opts.contains_key("simple_option")); + } + + #[test] + fn test_non_existent_nested_paths() { + let mut opts = IndexMap::new(); + opts.insert( + "platforms".to_string(), + r#" +[macos-x64] +url = "https://example.com/macos-x64.tar.gz" +"# + .to_string(), + ); + + let tool_opts = ToolVersionOptions { + opts, + ..Default::default() + }; + + // Test non-existent nested paths + assert_eq!( + tool_opts.get_nested_string("platforms.windows-x64.url"), + None + ); + assert_eq!( + tool_opts.get_nested_string("platforms.macos-x64.checksum"), + None + ); + assert_eq!(tool_opts.get_nested_string("config.database.host"), None); + } + + #[test] + fn test_indexmap_preserves_order() { + let mut tvo = ToolVersionOptions::default(); + + // Insert options in a specific order + tvo.opts.insert("zebra".to_string(), "last".to_string()); + tvo.opts.insert("alpha".to_string(), "first".to_string()); + tvo.opts.insert("beta".to_string(), "second".to_string()); + + // Collect keys to verify order is preserved + let keys: Vec<_> = tvo.opts.keys().collect(); + assert_eq!(keys, vec!["zebra", "alpha", "beta"]); + } +} diff --git a/xtasks/test/e2e b/xtasks/test/e2e index d17371656f..474bf47332 100755 --- a/xtasks/test/e2e +++ b/xtasks/test/e2e @@ -6,6 +6,38 @@ set -euo pipefail export RUST_TEST_THREADS=1 +# Show help +if [[ ${1:-} == --help ]]; then + cat >&2 <&2 + pushd e2e >/dev/null + fd -tf "^test_" | sort + popd >/dev/null + exit 0 +fi + if [[ ${1:-} == --all ]]; then ./e2e/run_all_tests else