From 497597a0976ecfae233bd92253b3a5bc9696acf9 Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Thu, 14 May 2026 00:39:03 +1000 Subject: [PATCH 1/5] refactor(backend): parse tool options per backend --- src/backend/aqua.rs | 149 ++++++++++++++---------- src/backend/github.rs | 259 ++++++++++++++++++++++++----------------- src/backend/http.rs | 153 ++++++++++++++++-------- src/backend/mod.rs | 5 +- src/backend/options.rs | 60 ++++++++++ src/backend/s3.rs | 119 ++++++++++++++----- src/backend/ubi.rs | 168 +++++++++++++++++--------- 7 files changed, 608 insertions(+), 305 deletions(-) create mode 100644 src/backend/options.rs diff --git a/src/backend/aqua.rs b/src/backend/aqua.rs index 015d6ca324..3ad1f80040 100644 --- a/src/backend/aqua.rs +++ b/src/backend/aqua.rs @@ -1,5 +1,6 @@ use crate::backend::VersionInfo; use crate::backend::backend_type::BackendType; +use crate::backend::options::BackendOptions; use crate::backend::platform_target::PlatformTarget; use crate::backend::static_helpers::get_filename_from_url; @@ -47,6 +48,66 @@ pub struct AquaBackend { version_tags_cache: CacheManager>, } +#[derive(Debug, Clone, Copy)] +struct AquaOptions<'a> { + values: BackendOptions<'a>, +} + +impl<'a> AquaOptions<'a> { + fn new(raw: &'a ToolVersionOptions) -> Self { + Self { + values: BackendOptions::new(raw), + } + } + + fn symlink_bins(&self) -> bool { + self.values.bool("symlink_bins") + } + + fn var(&self, name: &str) -> Result> { + let opts = self.values.raw(); + if let Some(toml::Value::Table(vars)) = opts.opts.get("vars") + && let Some(value) = vars.get(name) + { + return toml_string_var(&format!("vars.{name}"), value).map(Some); + } + opts.opts + .get(name) + .map(|value| toml_string_var(name, value).map(Some)) + .unwrap_or(Ok(None)) + } + + fn lockfile_options(&self) -> BTreeMap { + let mut result = BTreeMap::new(); + for (key, value) in self.values.raw().iter() { + if key == "symlink_bins" || EPHEMERAL_OPT_KEYS.contains(&key.as_str()) { + continue; + } + if key == "vars" { + if let toml::Value::Table(table) = value { + Self::insert_vars_lockfile_options(&mut result, table); + } + } else if let Some(value) = toml_value_to_string(value) { + let key = if key.starts_with("vars.") { + key.clone() + } else { + format!("vars.{key}") + }; + result.entry(key).or_insert(value); + } + } + result + } + + fn insert_vars_lockfile_options(result: &mut BTreeMap, table: &toml::Table) { + for (key, value) in table { + if let Some(value) = toml_value_to_string(value) { + result.insert(format!("vars.{key}"), value); + } + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct AquaFileLink { src: PathBuf, @@ -502,7 +563,9 @@ impl Backend for AquaBackend { ) -> Result> { let runtime_path = tv.runtime_path(); let mise_bins_dir = tv.install_path().join(MISE_BINS_DIR); - if self.symlink_bins(tv) || mise_bins_dir.is_dir() { + let request_options = tv.request.options(); + let opts = AquaOptions::new(&request_options); + if opts.symlink_bins() || mise_bins_dir.is_dir() { return Ok(vec![runtime_path.join(MISE_BINS_DIR)]); } @@ -521,8 +584,7 @@ impl Backend for AquaBackend { }); } - let request_options = tv.request.options(); - let cache_key = Self::lockfile_options(&request_options); + let cache_key = opts.lockfile_options(); let cache: CacheManager> = CacheManagerBuilder::new(tv.cache_path().join("bin_paths.msgpack.z")) .with_fresh_file(install_path.clone()) @@ -561,7 +623,8 @@ impl Backend for AquaBackend { request: &ToolRequest, _target: &PlatformTarget, ) -> BTreeMap { - Self::lockfile_options(&request.options()) + let request_options = request.options(); + AquaOptions::new(&request_options).lockfile_options() } fn fuzzy_match_filter( @@ -632,7 +695,8 @@ impl Backend for AquaBackend { // Using package_with_version() here would apply overrides for the current host // platform first, which can leak host-specific overrides into cross-platform lock. let pkg = AQUA_REGISTRY.package(&self.id).await?; - let opts = tv.request.options(); + let raw_opts = tv.request.options(); + let opts = AquaOptions::new(&raw_opts); let target_libc = Self::target_variant_libc(target); let pkg = pkg.with_version_libc(&versions, target_os, target_arch, target_libc.as_deref()); let pkg = Self::apply_aqua_libc_replacement(pkg, target_os, Self::target_libc(target)); @@ -819,7 +883,9 @@ impl AquaBackend { let target_libc = Self::target_variant_libc(&target); let pkg = pkg.with_version_libc(versions, target_os, target_arch, target_libc.as_deref()); let pkg = Self::apply_aqua_libc_replacement(pkg, target_os, Self::target_libc(&target)); - Self::apply_var_options(pkg, &tv.request.options()) + let raw_opts = tv.request.options(); + let opts = AquaOptions::new(&raw_opts); + Self::apply_var_options(pkg, &opts) } fn to_aqua_platform(target: &PlatformTarget) -> (&str, &str) { @@ -887,49 +953,19 @@ impl AquaBackend { pkg } - fn apply_var_options(pkg: AquaPackage, opts: &ToolVersionOptions) -> Result { + fn apply_var_options(pkg: AquaPackage, opts: &AquaOptions<'_>) -> Result { if pkg.vars.is_empty() { return Ok(pkg); } let mut var_values = HashMap::new(); for var in &pkg.vars { - if let Some(value) = aqua_var_option(opts, &var.name)? { + if let Some(value) = opts.var(&var.name)? { var_values.insert(var.name.clone(), value); } } pkg.with_var_values(var_values) } - fn lockfile_options(opts: &ToolVersionOptions) -> BTreeMap { - let mut result = BTreeMap::new(); - for (key, value) in opts.iter() { - if key == "symlink_bins" || EPHEMERAL_OPT_KEYS.contains(&key.as_str()) { - continue; - } - if key == "vars" { - if let toml::Value::Table(table) = value { - Self::insert_vars_lockfile_options(&mut result, table); - } - } else if let Some(value) = toml_value_to_string(value) { - let key = if key.starts_with("vars.") { - key.clone() - } else { - format!("vars.{key}") - }; - result.entry(key).or_insert(value); - } - } - result - } - - fn insert_vars_lockfile_options(result: &mut BTreeMap, table: &toml::Table) { - for (key, value) in table { - if let Some(value) = toml_value_to_string(value) { - result.insert(format!("vars.{key}"), value); - } - } - } - fn has_native_cosign(cosign: &AquaCosign) -> bool { cosign.enabled != Some(false) && (cosign.key.is_some() || cosign.bundle.is_some()) } @@ -2251,7 +2287,9 @@ impl AquaBackend { } } - if self.symlink_bins(tv) { + let raw_opts = tv.request.options(); + let opts = AquaOptions::new(&raw_opts); + if opts.symlink_bins() { self.create_symlink_bin_dir(tv, &srcs)?; } @@ -2275,13 +2313,6 @@ impl AquaBackend { Ok(()) } - fn symlink_bins(&self, tv: &ToolVersion) -> bool { - tv.request - .options() - .get_string("symlink_bins") - .is_some_and(|v| v == "true" || v == "1") - } - fn srcs(pkg: &AquaPackage, tv: &ToolVersion) -> Result> { Self::srcs_for_platform(pkg, &tv.version, &tv.install_path(), os(), arch()) } @@ -2478,18 +2509,6 @@ fn toml_value_to_string(value: &toml::Value) -> Option { } } -fn aqua_var_option(opts: &ToolVersionOptions, name: &str) -> Result> { - if let Some(toml::Value::Table(vars)) = opts.opts.get("vars") - && let Some(value) = vars.get(name) - { - return toml_string_var(&format!("vars.{name}"), value).map(Some); - } - opts.opts - .get(name) - .map(|value| toml_string_var(name, value).map(Some)) - .unwrap_or(Ok(None)) -} - fn toml_string_var(key: &str, value: &toml::Value) -> Result { match value { toml::Value::String(s) => Ok(s.clone()), @@ -2679,6 +2698,7 @@ mod tests { opts.opts .insert("vars".to_string(), toml::Value::Table(vars)); + let opts = AquaOptions::new(&opts); let pkg = AquaBackend::apply_var_options(pkg, &opts).unwrap(); assert_eq!( @@ -2700,6 +2720,7 @@ mod tests { opts.opts .insert("vars".to_string(), toml::Value::Table(vars)); + let opts = AquaOptions::new(&opts); let err = AquaBackend::apply_var_options(pkg, &opts).unwrap_err(); assert!( @@ -2713,7 +2734,9 @@ mod tests { fn test_apply_var_options_errors_for_missing_required_var() { let mut pkg = AquaPackage::default(); pkg.vars = vec![aqua_var("go_version", true)]; - let err = AquaBackend::apply_var_options(pkg, &ToolVersionOptions::default()).unwrap_err(); + let opts = ToolVersionOptions::default(); + let opts = AquaOptions::new(&opts); + let err = AquaBackend::apply_var_options(pkg, &opts).unwrap_err(); assert!( err.to_string() @@ -2742,7 +2765,7 @@ mod tests { opts.opts .insert("vars".to_string(), toml::Value::Table(vars)); - let lock_opts = AquaBackend::lockfile_options(&opts); + let lock_opts = AquaOptions::new(&opts).lockfile_options(); assert_eq!(lock_opts.get("vars.channel"), Some(&"stable".to_string())); assert_eq!(lock_opts.get("vars.go_version"), Some(&"1.24".to_string())); @@ -2769,8 +2792,8 @@ mod tests { .insert("vars".to_string(), toml::Value::Table(vars)); assert_eq!( - AquaBackend::lockfile_options(&top_level), - AquaBackend::lockfile_options(&nested) + AquaOptions::new(&top_level).lockfile_options(), + AquaOptions::new(&nested).lockfile_options() ); } @@ -2789,7 +2812,7 @@ mod tests { opts.opts .insert("vars".to_string(), toml::Value::Table(vars)); - let lock_opts = AquaBackend::lockfile_options(&opts); + let lock_opts = AquaOptions::new(&opts).lockfile_options(); assert_eq!(lock_opts.get("vars.channel"), Some(&"beta".to_string())); } diff --git a/src/backend/github.rs b/src/backend/github.rs index 1369668627..f77dec225e 100644 --- a/src/backend/github.rs +++ b/src/backend/github.rs @@ -1,10 +1,11 @@ use crate::backend::VersionInfo; use crate::backend::asset_matcher::{self, Asset, AssetPicker, ChecksumFetcher}; use crate::backend::backend_type::BackendType; +use crate::backend::options::{BackendOptions, is_truthy}; use crate::backend::platform_target::PlatformTarget; use crate::backend::static_helpers::{ - get_filename_from_url, install_artifact, lookup_platform_key, lookup_platform_key_for_target, - template_string, try_with_v_prefix, try_with_v_prefix_and_repo, verify_artifact, + get_filename_from_url, install_artifact, template_string, try_with_v_prefix, + try_with_v_prefix_and_repo, verify_artifact, }; use crate::backend::{MISE_BINS_DIR, SecurityFeature, runtime_path_for_install_path}; use crate::cli::args::{BackendArg, ToolVersionType}; @@ -40,6 +41,82 @@ const DEFAULT_GITHUB_API_BASE_URL: &str = "https://api.github.com"; const DEFAULT_GITLAB_API_BASE_URL: &str = "https://gitlab.com/api/v4"; const DEFAULT_FORGEJO_API_BASE_URL: &str = "https://codeberg.org/api/v1"; +#[derive(Debug, Clone, Copy)] +struct GitBackendOptions<'a> { + values: BackendOptions<'a>, + default_api_url: &'static str, +} + +impl<'a> GitBackendOptions<'a> { + fn new(raw: &'a ToolVersionOptions, default_api_url: &'static str) -> Self { + Self { + values: BackendOptions::new(raw), + default_api_url, + } + } + + fn raw(&self) -> &'a ToolVersionOptions { + self.values.raw() + } + + fn api_url(&self) -> String { + self.values + .str("api_url") + .unwrap_or(self.default_api_url) + .to_string() + } + + fn version_prefix(&self) -> Option<&'a str> { + self.values.str("version_prefix") + } + + fn checksum(&self) -> Option { + self.values.platform_string("checksum") + } + + fn bin_path(&self) -> Option { + self.values.platform_string("bin_path") + } + + fn asset_pattern_for_target(&self, target: &PlatformTarget) -> Option { + self.values + .platform_string_for_target("asset_pattern", target) + } + + fn direct_url_for_target(&self, target: &PlatformTarget) -> Option { + self.values + .platform_string_for_target_without_base("url", target) + } + + fn no_app(&self) -> bool { + self.values + .string("no_app") + .is_some_and(|value| is_truthy(&value)) + } + + fn filter_bins(&self) -> Option> { + self.values + .platform_string("filter_bins") + .map(|filter_bins| { + filter_bins + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + } + + fn lockfile_options(&self) -> BTreeMap { + let mut result = BTreeMap::new(); + for key in ["asset_pattern", "url", "version_prefix"] { + if let Some(value) = self.values.str(key) { + result.insert(key.to_string(), value.to_string()); + } + } + result + } +} + /// GitHub artifact attestations are only served by https://api.github.com. GHE /// Server doesn't implement the attestations endpoint, so any verification /// attempt against a custom api_url will fail. Callers gate on this so users @@ -107,8 +184,9 @@ impl Backend for UnifiedGitBackend { // Get the latest release to check for security assets let repo = self.ba.tool_name(); - let opts = self.ba.opts(); - let api_url = self.get_api_url(&opts); + let raw_opts = self.ba.opts(); + let opts = self.options(&raw_opts); + let api_url = opts.api_url(); let releases = github::list_releases_from_url(api_url.as_str(), &repo) .await @@ -168,9 +246,10 @@ impl Backend for UnifiedGitBackend { async fn _list_remote_versions(&self, config: &Arc) -> Result> { let repo = self.ba.tool_name(); let id = self.ba.to_string(); - let opts = config.get_tool_opts_with_overrides(&self.ba).await?; - let api_url = self.get_api_url(&opts); - let version_prefix = opts.get("version_prefix"); + let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?; + let opts = self.options(&raw_opts); + let api_url = opts.api_url(); + let version_prefix = opts.version_prefix(); // Derive web URL base from API URL for enterprise support let web_url_base = if self.is_gitlab() { @@ -266,16 +345,17 @@ impl Backend for UnifiedGitBackend { } let repo = self.ba.tool_name(); - let opts = config.get_tool_opts_with_overrides(&self.ba).await?; - let api_url = self.get_api_url(&opts); - let version_prefix = opts.get("version_prefix"); + let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?; + let opts = self.options(&raw_opts); + let api_url = opts.api_url(); + let version_prefix = opts.version_prefix(); // When `prerelease = true`, skip the `/releases/latest` shortcut // (which returns whichever release the repo owner marked as "Latest", // defaulting to the newest non-prerelease). Returning `None` lets the // trait's `latest_version` fall through to `latest_version_for_query`, // which resolves against the full list — now including pre-releases. - if self.include_prereleases(&opts) { + if self.include_prereleases(opts.raw()) { return Ok(None); } @@ -316,8 +396,9 @@ impl Backend for UnifiedGitBackend { mut tv: ToolVersion, ) -> Result { let repo = self.repo(); - let opts = ctx.config.get_tool_opts_with_overrides(&self.ba).await?; - let api_url = self.get_api_url(&opts); + let raw_opts = ctx.config.get_tool_opts_with_overrides(&self.ba).await?; + let opts = self.options(&raw_opts); + let api_url = opts.api_url(); // Check if URL already exists in lockfile platforms first let platform_key = self.get_platform_key(); @@ -353,8 +434,10 @@ impl Backend for UnifiedGitBackend { _config: &Arc, tv: &ToolVersion, ) -> Result> { + let raw_opts = tv.request.options(); + let opts = self.options(&raw_opts); let mise_bins_dir = tv.install_path().join(MISE_BINS_DIR); - if self.get_filter_bins(tv).is_some() || mise_bins_dir.is_dir() { + if opts.filter_bins().is_some() || mise_bins_dir.is_dir() { return Ok(vec![tv.runtime_path().join(MISE_BINS_DIR)]); } @@ -370,17 +453,8 @@ impl Backend for UnifiedGitBackend { request: &ToolRequest, _target: &PlatformTarget, ) -> BTreeMap { - let opts = request.options(); - let mut result = BTreeMap::new(); - - // These options affect which artifact is downloaded - for key in ["asset_pattern", "url", "version_prefix"] { - if let Some(value) = opts.get(key) { - result.insert(key.to_string(), value.to_string()); - } - } - - result + let raw_opts = request.options(); + self.options(&raw_opts).lockfile_options() } /// Resolve platform-specific lock information for cross-platform lockfile generation. @@ -391,8 +465,9 @@ impl Backend for UnifiedGitBackend { target: &PlatformTarget, ) -> Result { let repo = self.repo(); - let opts = tv.request.options(); - let api_url = self.get_api_url(&opts); + let raw_opts = tv.request.options(); + let opts = self.options(&raw_opts); + let api_url = opts.api_url(); // Resolve asset for the target platform let asset = self @@ -471,12 +546,26 @@ impl UnifiedGitBackend { Self { ba: Arc::new(ba) } } + fn options<'a>(&self, raw: &'a ToolVersionOptions) -> GitBackendOptions<'a> { + GitBackendOptions::new(raw, self.default_api_url()) + } + + fn default_api_url(&self) -> &'static str { + if self.is_gitlab() { + DEFAULT_GITLAB_API_BASE_URL + } else if self.is_forgejo() { + DEFAULT_FORGEJO_API_BASE_URL + } else { + DEFAULT_GITHUB_API_BASE_URL + } + } + /// Detect what provenance type is available for a release by checking its assets /// and querying the GitHub attestation API. async fn detect_provenance_type( &self, tv: &ToolVersion, - opts: &ToolVersionOptions, + opts: &GitBackendOptions<'_>, repo: &str, api_url: &str, asset_digest: Option<&str>, @@ -484,7 +573,7 @@ impl UnifiedGitBackend { ) -> (Option, Option) { let settings = Settings::get(); let version = &tv.version; - let version_prefix = opts.get("version_prefix"); + let version_prefix = opts.version_prefix(); let mut github_attestations = None; let release = @@ -563,7 +652,7 @@ impl UnifiedGitBackend { async fn verify_provenance_at_lock_time( &self, tv: &ToolVersion, - opts: &ToolVersionOptions, + opts: &GitBackendOptions<'_>, repo: &str, api_url: &str, asset: &ReleaseAsset, @@ -638,7 +727,7 @@ impl UnifiedGitBackend { // Fall back to SLSA provenance if settings.slsa && settings.github.slsa { let version = &tv.version; - let version_prefix = opts.get("version_prefix"); + let version_prefix = opts.version_prefix(); let release = try_with_v_prefix_and_repo(version, version_prefix, Some(repo), |candidate| { let api_url = api_url.to_string(); @@ -733,33 +822,19 @@ impl UnifiedGitBackend { assets.cloned().collect::>().join(", ") } - fn get_api_url(&self, opts: &ToolVersionOptions) -> String { - opts.get("api_url") - .unwrap_or(if self.is_gitlab() { - DEFAULT_GITLAB_API_BASE_URL - } else if self.is_forgejo() { - DEFAULT_FORGEJO_API_BASE_URL - } else { - DEFAULT_GITHUB_API_BASE_URL - }) - .to_string() - } - /// Downloads and installs the asset async fn download_and_install( &self, ctx: &InstallContext, tv: &mut ToolVersion, asset: &ReleaseAsset, - opts: &ToolVersionOptions, + opts: &GitBackendOptions<'_>, ) -> Result<()> { let filename = asset.name.clone(); let file_path = tv.download_path().join(&filename); // Check if we'll verify checksum - let has_checksum = lookup_platform_key(opts, "checksum") - .or_else(|| opts.get("checksum").map(|s| s.to_string())) - .is_some(); + let has_checksum = opts.checksum().is_some(); // Store the asset URL and digest (if available) in the tool version let platform_key = self.get_platform_key(); @@ -823,7 +898,7 @@ impl UnifiedGitBackend { // Verify and install ctx.pr.next_operation(); if has_checksum { - verify_artifact(tv, &file_path, opts, Some(ctx.pr.as_ref()))?; + verify_artifact(tv, &file_path, opts.raw(), Some(ctx.pr.as_ref()))?; } // Check before verify_checksum, which may generate a new checksum from the @@ -857,9 +932,9 @@ impl UnifiedGitBackend { } ctx.pr.next_operation(); - install_artifact(tv, &file_path, opts, Some(ctx.pr.as_ref()))?; + install_artifact(tv, &file_path, opts.raw(), Some(ctx.pr.as_ref()))?; - if let Some(bins) = self.get_filter_bins(tv) { + if let Some(bins) = opts.filter_bins() { self.create_symlink_bin_dir(tv, bins)?; } @@ -868,10 +943,9 @@ impl UnifiedGitBackend { /// Discovers bin paths in the installation directory fn discover_bin_paths(&self, tv: &ToolVersion) -> Result> { - let opts = tv.request.options(); - if let Some(bin_path_template) = lookup_platform_key(&opts, "bin_path") - .or_else(|| opts.get("bin_path").map(|s| s.to_string())) - { + let raw_opts = tv.request.options(); + let opts = self.options(&raw_opts); + if let Some(bin_path_template) = opts.bin_path() { let bin_path = template_string(&bin_path_template, tv); return Ok(vec![tv.install_path().join(&bin_path)]); } @@ -946,7 +1020,7 @@ impl UnifiedGitBackend { async fn resolve_asset_url( &self, tv: &ToolVersion, - opts: &ToolVersionOptions, + opts: &GitBackendOptions<'_>, repo: &str, api_url: &str, ) -> Result { @@ -959,13 +1033,13 @@ impl UnifiedGitBackend { async fn resolve_asset_url_for_target( &self, tv: &ToolVersion, - opts: &ToolVersionOptions, + opts: &GitBackendOptions<'_>, repo: &str, api_url: &str, target: &PlatformTarget, ) -> Result { // Check for direct platform-specific URLs first - if let Some(direct_url) = lookup_platform_key_for_target(opts, "url", target) { + if let Some(direct_url) = opts.direct_url_for_target(target) { return Ok(ReleaseAsset { name: get_filename_from_url(&direct_url), url: direct_url.clone(), @@ -975,7 +1049,7 @@ impl UnifiedGitBackend { } let version = &tv.version; - let version_prefix = opts.get("version_prefix"); + let version_prefix = opts.version_prefix(); if self.is_gitlab() { try_with_v_prefix(version, version_prefix, |candidate| async move { self.resolve_gitlab_asset_url_for_target( @@ -1013,7 +1087,7 @@ impl UnifiedGitBackend { async fn resolve_github_asset_url_for_target( &self, tv: &ToolVersion, - opts: &ToolVersionOptions, + opts: &GitBackendOptions<'_>, repo: &str, api_url: &str, version: &str, @@ -1030,9 +1104,7 @@ impl UnifiedGitBackend { .collect(); // Try explicit pattern first - if let Some(pattern) = lookup_platform_key_for_target(opts, "asset_pattern", target) - .or_else(|| opts.get("asset_pattern").map(|s| s.to_string())) - { + if let Some(pattern) = opts.asset_pattern_for_target(target) { // Template the pattern for the target platform let templated_pattern = template_string_for_target(&pattern, tv, target); @@ -1065,13 +1137,9 @@ impl UnifiedGitBackend { } // Fall back to auto-detection for target platform - let no_app = opts - .get("no_app") - .and_then(|v| v.parse::().ok()) - .unwrap_or(false); let asset_name = asset_matcher::AssetMatcher::new() .for_target(target) - .with_no_app(no_app) + .with_no_app(opts.no_app()) .pick_from(&available_assets)? .name; let asset = self @@ -1104,7 +1172,7 @@ impl UnifiedGitBackend { async fn resolve_gitlab_asset_url_for_target( &self, tv: &ToolVersion, - opts: &ToolVersionOptions, + opts: &GitBackendOptions<'_>, repo: &str, api_url: &str, version: &str, @@ -1127,9 +1195,7 @@ impl UnifiedGitBackend { .collect(); // Try explicit pattern first - if let Some(pattern) = lookup_platform_key_for_target(opts, "asset_pattern", target) - .or_else(|| opts.get("asset_pattern").map(|s| s.to_string())) - { + if let Some(pattern) = opts.asset_pattern_for_target(target) { // Template the pattern for the target platform let templated_pattern = template_string_for_target(&pattern, tv, target); @@ -1160,13 +1226,9 @@ impl UnifiedGitBackend { } // Fall back to auto-detection for target platform - let no_app = opts - .get("no_app") - .and_then(|v| v.parse::().ok()) - .unwrap_or(false); let asset_name = asset_matcher::AssetMatcher::new() .for_target(target) - .with_no_app(no_app) + .with_no_app(opts.no_app()) .pick_from(&available_assets)? .name; let asset = self @@ -1196,7 +1258,7 @@ impl UnifiedGitBackend { async fn resolve_forgejo_asset_url_for_target( &self, tv: &ToolVersion, - opts: &ToolVersionOptions, + opts: &GitBackendOptions<'_>, repo: &str, api_url: &str, version: &str, @@ -1222,9 +1284,7 @@ impl UnifiedGitBackend { }; // Try explicit pattern first - if let Some(pattern) = lookup_platform_key_for_target(opts, "asset_pattern", target) - .or_else(|| opts.get("asset_pattern").map(|s| s.to_string())) - { + if let Some(pattern) = opts.asset_pattern_for_target(target) { // Template the pattern for the target platform let templated_pattern = template_string_for_target(&pattern, tv, target); @@ -1254,13 +1314,9 @@ impl UnifiedGitBackend { } // Fall back to auto-detection for target platform - let no_app = opts - .get("no_app") - .and_then(|v| v.parse::().ok()) - .unwrap_or(false); let asset_name = asset_matcher::AssetMatcher::new() .for_target(target) - .with_no_app(no_app) + .with_no_app(opts.no_app()) .pick_from(&available_assets)? .name; let asset = self @@ -1319,9 +1375,9 @@ impl UnifiedGitBackend { } } - fn strip_version_prefix(&self, tag_name: &str, opts: &ToolVersionOptions) -> String { + fn strip_version_prefix(&self, tag_name: &str, opts: &GitBackendOptions<'_>) -> String { // If a custom version_prefix is configured, strip it first - if let Some(prefix) = opts.get("version_prefix") + if let Some(prefix) = opts.version_prefix() && let Some(stripped) = tag_name.strip_prefix(prefix) { return stripped.to_string(); @@ -1380,20 +1436,6 @@ impl UnifiedGitBackend { } } - fn get_filter_bins(&self, tv: &ToolVersion) -> Option> { - let opts = tv.request.options(); - let filter_bins = lookup_platform_key(&opts, "filter_bins") - .or_else(|| opts.get("filter_bins").map(|s| s.to_string()))?; - - Some( - filter_bins - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(), - ) - } - /// Creates a `.mise-bins` directory with symlinks only to the binaries specified in filter_bins. fn create_symlink_bin_dir(&self, tv: &ToolVersion, bins: Vec) -> Result<()> { let symlink_dir = tv.install_path().join(MISE_BINS_DIR); @@ -1509,7 +1551,9 @@ impl UnifiedGitBackend { // doesn't support them (e.g. GHE Server), surface a clear, actionable // error rather than falling through to the generic "downgrade attack" // path below. - let api_url = self.get_api_url(&tv.request.options()); + let raw_opts = tv.request.options(); + let opts = self.options(&raw_opts); + let api_url = opts.api_url(); if !attestations_supported(&api_url) && let Some(expected) = expected_provenance && expected.is_github_attestations() @@ -1688,11 +1732,12 @@ impl UnifiedGitBackend { // Get the release to find provenance assets let repo = self.repo(); - let opts = tv.request.options(); + let raw_opts = tv.request.options(); + let opts = self.options(&raw_opts); let version = &tv.version; // Try to get the release (with version prefix support) - let version_prefix = opts.get("version_prefix"); + let version_prefix = opts.version_prefix(); let release = match try_with_v_prefix_and_repo(version, version_prefix, Some(&repo), |candidate| { let api_url = api_url.to_string(); @@ -1874,7 +1919,7 @@ mod tests { fn create_test_backend() -> UnifiedGitBackend { UnifiedGitBackend::from_arg(BackendArg::new( - "github".to_string(), + "github:test/repo".to_string(), Some("github:test/repo".to_string()), )) } @@ -1889,7 +1934,8 @@ mod tests { #[test] fn test_version_prefix_functionality() { let backend = create_test_backend(); - let default_opts = ToolVersionOptions::default(); + let default_raw_opts = ToolVersionOptions::default(); + let default_opts = backend.options(&default_raw_opts); // Test with no version prefix configured assert_eq!( @@ -1934,6 +1980,7 @@ mod tests { "version_prefix".to_string(), toml::Value::String("release-".to_string()), ); + let opts = backend.options(&opts); assert_eq!( backend.strip_version_prefix("release-1.0.0", &opts), diff --git a/src/backend/http.rs b/src/backend/http.rs index 1449a46081..4e4e972888 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -1,10 +1,11 @@ use crate::backend::Backend; use crate::backend::VersionInfo; use crate::backend::backend_type::BackendType; +use crate::backend::options::BackendOptions; use crate::backend::runtime_path_for_install_path; use crate::backend::static_helpers::{ - clean_binary_name, get_filename_from_url, list_available_platforms_with_key, - lookup_platform_key, rename_executable_in_dir, template_string, verify_artifact, + clean_binary_name, get_filename_from_url, rename_executable_in_dir, template_string, + verify_artifact, }; use crate::backend::version_list; use crate::cli::args::BackendArg; @@ -28,11 +29,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; const HTTP_TARBALLS_DIR: &str = "http-tarballs"; const METADATA_FILE: &str = "metadata.json"; -/// Helper to get an option value with platform-specific fallback -fn get_opt(opts: &ToolVersionOptions, key: &str) -> Option { - lookup_platform_key(opts, key).or_else(|| opts.get_string(key)) -} - /// Metadata stored alongside cached extractions #[derive(Debug, Serialize, Deserialize)] struct CacheMetadata { @@ -66,9 +62,9 @@ struct FileInfo { impl FileInfo { /// Analyze a file path and options to determine format information - fn new(file_path: &Path, opts: &ToolVersionOptions) -> Self { + fn new(file_path: &Path, opts: &HttpOptions<'_>) -> Self { // Apply format config to determine effective extension - let effective_path = if let Some(added_ext) = get_opt(opts, "format") { + let effective_path = if let Some(added_ext) = opts.format() { let mut path = file_path.to_path_buf(); let current_ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let new_ext = if current_ext.is_empty() { @@ -128,6 +124,71 @@ pub struct HttpBackend { ba: Arc, } +#[derive(Debug, Clone, Copy)] +struct HttpOptions<'a> { + values: BackendOptions<'a>, +} + +impl<'a> HttpOptions<'a> { + fn new(raw: &'a ToolVersionOptions) -> Self { + Self { + values: BackendOptions::new(raw), + } + } + + fn raw(&self) -> &'a ToolVersionOptions { + self.values.raw() + } + + fn url(&self) -> Option { + self.values.platform_string("url") + } + + fn checksum(&self) -> Option { + self.values.platform_string("checksum") + } + + fn format(&self) -> Option { + self.values.platform_string("format") + } + + fn strip_components(&self) -> Option { + self.values.platform_string("strip_components") + } + + fn bin(&self) -> Option { + self.values.platform_string("bin") + } + + fn rename_exe(&self) -> Option { + self.values.platform_string("rename_exe") + } + + fn bin_path(&self) -> Option { + self.values.platform_string("bin_path") + } + + fn version_list_url(&self) -> Option<&'a str> { + self.values.str("version_list_url") + } + + fn version_regex(&self) -> Option<&'a str> { + self.values.str("version_regex") + } + + fn version_json_path(&self) -> Option<&'a str> { + self.values.str("version_json_path") + } + + fn version_expr(&self) -> Option<&'a str> { + self.values.str("version_expr") + } + + fn url_platforms(&self) -> Vec { + self.values.available_platforms_with_key("url") + } +} + impl HttpBackend { pub fn from_arg(ba: BackendArg) -> Self { Self { ba: Arc::new(ba) } @@ -162,23 +223,23 @@ impl HttpBackend { // ------------------------------------------------------------------------- /// Generate a cache key based on file content and extraction options - fn cache_key(&self, file_path: &Path, opts: &ToolVersionOptions) -> Result { + fn cache_key(&self, file_path: &Path, opts: &HttpOptions<'_>) -> Result { let checksum = hash::file_hash_blake3(file_path, None)?; // Include extraction options that affect output structure // Note: bin_path is NOT included - handled at symlink time for deduplication let mut parts = vec![checksum]; - if let Some(strip) = get_opt(opts, "strip_components") { + if let Some(strip) = opts.strip_components() { parts.push(format!("strip_{strip}")); } // Include rename_exe in cache key since it modifies the extracted content - if let Some(rename) = get_opt(opts, "rename_exe") { + if let Some(rename) = opts.rename_exe() { parts.push(format!("rename_{rename}")); // When rename_exe is used, bin_path affects where the rename happens, // so different bin_path values result in different cached content - if let Some(bin_path) = get_opt(opts, "bin_path") { + if let Some(bin_path) = opts.bin_path() { parts.push(format!("binpath_{bin_path}")); } } @@ -197,10 +258,10 @@ impl HttpBackend { &self, file_path: &Path, file_info: &FileInfo, - opts: &ToolVersionOptions, + opts: &HttpOptions<'_>, ) -> String { // Check for explicit bin name first - if let Some(bin_name) = get_opt(opts, "bin") { + if let Some(bin_name) = opts.bin() { return bin_name; } @@ -255,7 +316,7 @@ impl HttpBackend { file_path: &Path, cache_key: &str, url: &str, - opts: &ToolVersionOptions, + opts: &HttpOptions<'_>, pr: Option<&dyn SingleReport>, ) -> Result { let cache_path = self.cache_path(cache_key); @@ -297,7 +358,7 @@ impl HttpBackend { tv: &ToolVersion, dest: &Path, file_path: &Path, - opts: &ToolVersionOptions, + opts: &HttpOptions<'_>, pr: Option<&dyn SingleReport>, ) -> Result { file::create_dir_all(dest)?; @@ -319,7 +380,7 @@ impl HttpBackend { dest: &Path, file_path: &Path, file_info: &FileInfo, - opts: &ToolVersionOptions, + opts: &HttpOptions<'_>, pr: Option<&dyn SingleReport>, ) -> Result { let filename = self.dest_filename(file_path, file_info, opts); @@ -349,7 +410,7 @@ impl HttpBackend { dest: &Path, file_path: &Path, file_info: &FileInfo, - opts: &ToolVersionOptions, + opts: &HttpOptions<'_>, pr: Option<&dyn SingleReport>, ) -> Result { let filename = self.dest_filename(file_path, file_info, opts); @@ -373,15 +434,15 @@ impl HttpBackend { dest: &Path, file_path: &Path, file_info: &FileInfo, - opts: &ToolVersionOptions, + opts: &HttpOptions<'_>, pr: Option<&dyn SingleReport>, ) -> Result { let mut strip_components: Option = - get_opt(opts, "strip_components").and_then(|s| s.parse().ok()); + opts.strip_components().and_then(|s| s.parse().ok()); // Auto-detect strip_components=1 for single-directory archives if strip_components.is_none() - && get_opt(opts, "bin_path").is_none() + && opts.bin_path().is_none() && file::should_strip_components(file_path, file_info.format).unwrap_or(false) { debug!("Auto-detected single directory archive, using strip_components=1"); @@ -398,10 +459,10 @@ impl HttpBackend { file::untar(file_path, dest, &tar_opts)?; // Handle rename_exe option for archives - if let Some(rename_to) = get_opt(opts, "rename_exe") { + if let Some(rename_to) = opts.rename_exe() { // When bin_path is not explicitly set, auto-detect bin/ subdirectory to match // the same logic used by discover_bin_paths() for PATH construction - let search_dir = if let Some(bin_path_template) = get_opt(opts, "bin_path") { + let search_dir = if let Some(bin_path_template) = opts.bin_path() { let bin_path = template_string(&bin_path_template, tv); dest.join(&bin_path) } else { @@ -426,11 +487,11 @@ impl HttpBackend { cache_key: &str, url: &str, file_path: &Path, - opts: &ToolVersionOptions, + opts: &HttpOptions<'_>, ) -> Result<()> { let metadata = CacheMetadata { url: url.to_string(), - checksum: get_opt(opts, "checksum"), + checksum: opts.checksum(), size: file_path.metadata()?.len(), extracted_at: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), platform: self.get_platform_key(), @@ -451,7 +512,7 @@ impl HttpBackend { tv: &ToolVersion, cache_key: &str, extraction_type: &ExtractionType, - opts: &ToolVersionOptions, + opts: &HttpOptions<'_>, ) -> Result<()> { let cache_path = self.cache_path(cache_key); @@ -474,7 +535,7 @@ impl HttpBackend { // Handle raw files with bin_path specially for deduplication if let ExtractionType::RawFile { filename } = extraction_type - && let Some(bin_path_template) = get_opt(opts, "bin_path") + && let Some(bin_path_template) = opts.bin_path() { let bin_path = template_string(&bin_path_template, tv); let dest_dir = install_path.join(&bin_path); @@ -565,16 +626,17 @@ impl HttpBackend { /// Fetch versions from version_list_url if configured async fn fetch_versions(&self, config: &Arc) -> Result> { - let opts = config.get_tool_opts_with_overrides(&self.ba).await?; + let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?; + let opts = HttpOptions::new(&raw_opts); - let url = match opts.get("version_list_url") { + let url = match opts.version_list_url() { Some(url) => url.to_string(), None => return Ok(vec![]), }; - let regex = opts.get("version_regex"); - let json_path = opts.get("version_json_path"); - let version_expr = opts.get("version_expr"); + let regex = opts.version_regex(); + let json_path = opts.version_json_path(); + let version_expr = opts.version_expr(); version_list::fetch_versions(&url, regex, json_path, version_expr).await } @@ -618,12 +680,9 @@ impl Backend for HttpBackend { } async fn install_operation_count(&self, tv: &ToolVersion, _ctx: &InstallContext) -> usize { - let opts = tv.request.options(); - super::http_install_operation_count( - get_opt(&opts, "checksum").is_some(), - &self.get_platform_key(), - tv, - ) + let raw_opts = tv.request.options(); + let opts = HttpOptions::new(&raw_opts); + super::http_install_operation_count(opts.checksum().is_some(), &self.get_platform_key(), tv) } async fn _list_remote_versions(&self, config: &Arc) -> Result> { @@ -642,12 +701,13 @@ impl Backend for HttpBackend { ctx: &InstallContext, mut tv: ToolVersion, ) -> Result { - let opts = tv.request.options(); + let raw_opts = tv.request.options(); + let opts = HttpOptions::new(&raw_opts); // Get URL template - let url_template = get_opt(&opts, "url").ok_or_else(|| { + let url_template = opts.url().ok_or_else(|| { let platform_key = self.get_platform_key(); - let available = list_available_platforms_with_key(&opts, "url"); + let available = opts.url_platforms(); if !available.is_empty() { eyre::eyre!( "No URL for platform {platform_key}. Available: {}. \ @@ -686,10 +746,10 @@ impl Backend for HttpBackend { .await?; // Verify artifact (checksum if provided) - if get_opt(&opts, "checksum").is_some() { + if opts.checksum().is_some() { ctx.pr.next_operation(); } - verify_artifact(&tv, &file_path, &opts, Some(ctx.pr.as_ref()))?; + verify_artifact(&tv, &file_path, opts.raw(), Some(ctx.pr.as_ref()))?; // Generate cache key let cache_key = self.cache_key(&file_path, &opts)?; @@ -739,11 +799,12 @@ impl Backend for HttpBackend { _config: &Arc, tv: &ToolVersion, ) -> Result> { - let opts = tv.request.options(); + let raw_opts = tv.request.options(); + let opts = HttpOptions::new(&raw_opts); let install_path = tv.install_path(); // Check for explicit bin_path - if let Some(bin_path_template) = get_opt(&opts, "bin_path") { + if let Some(bin_path_template) = opts.bin_path() { let bin_path = template_string(&bin_path_template, tv); return Ok(vec![tv.runtime_path().join(bin_path)]); } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 29fa5545a1..0090b9d348 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -64,6 +64,7 @@ pub mod go; pub mod http; pub mod jq; pub mod npm; +pub(crate) mod options; pub mod pipx; pub mod platform_target; pub mod s3; @@ -356,7 +357,9 @@ pub fn install_time_option_keys_for_type(backend_type: &BackendType) -> Vec http::install_time_option_keys(), BackendType::S3 => s3::install_time_option_keys(), - BackendType::Github | BackendType::Gitlab => github::install_time_option_keys(), + BackendType::Github | BackendType::Gitlab | BackendType::Forgejo => { + github::install_time_option_keys() + } BackendType::Ubi => ubi::install_time_option_keys(), BackendType::Cargo => cargo::install_time_option_keys(), BackendType::Go => go::install_time_option_keys(), diff --git a/src/backend/options.rs b/src/backend/options.rs new file mode 100644 index 0000000000..a80c885d05 --- /dev/null +++ b/src/backend/options.rs @@ -0,0 +1,60 @@ +use crate::backend::platform_target::PlatformTarget; +use crate::backend::static_helpers::{ + list_available_platforms_with_key, lookup_platform_key_for_target, lookup_with_fallback, +}; +use crate::toolset::ToolVersionOptions; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct BackendOptions<'a> { + raw: &'a ToolVersionOptions, +} + +impl<'a> BackendOptions<'a> { + pub(crate) fn new(raw: &'a ToolVersionOptions) -> Self { + Self { raw } + } + + pub(crate) fn raw(&self) -> &'a ToolVersionOptions { + self.raw + } + + pub(crate) fn string(&self, key: &str) -> Option { + self.raw.get_string(key) + } + + pub(crate) fn str(&self, key: &str) -> Option<&'a str> { + self.raw.get(key) + } + + pub(crate) fn platform_string(&self, key: &str) -> Option { + lookup_with_fallback(self.raw, key) + } + + pub(crate) fn platform_string_for_target( + &self, + key: &str, + target: &PlatformTarget, + ) -> Option { + lookup_platform_key_for_target(self.raw, key, target).or_else(|| self.raw.get_string(key)) + } + + pub(crate) fn platform_string_for_target_without_base( + &self, + key: &str, + target: &PlatformTarget, + ) -> Option { + lookup_platform_key_for_target(self.raw, key, target) + } + + pub(crate) fn bool(&self, key: &str) -> bool { + self.string(key).is_some_and(|v| is_truthy(&v)) + } + + pub(crate) fn available_platforms_with_key(&self, key: &str) -> Vec { + list_available_platforms_with_key(self.raw, key) + } +} + +pub(crate) fn is_truthy(value: &str) -> bool { + matches!(value.trim(), "true" | "1") +} diff --git a/src/backend/s3.rs b/src/backend/s3.rs index 003e1197d9..05dff5cd9a 100644 --- a/src/backend/s3.rs +++ b/src/backend/s3.rs @@ -38,8 +38,9 @@ pub const EXPERIMENTAL: bool = true; use crate::backend::backend_type::BackendType; +use crate::backend::options::BackendOptions; use crate::backend::static_helpers::{ - get_filename_from_url, install_artifact, lookup_with_fallback, template_string, verify_artifact, + get_filename_from_url, install_artifact, template_string, verify_artifact, }; use crate::backend::version_list; use crate::backend::{Backend, VersionInfo, runtime_path_for_install_path}; @@ -99,6 +100,63 @@ pub struct S3Backend { client: OnceCell, } +#[derive(Debug, Clone, Copy)] +struct S3Options<'a> { + values: BackendOptions<'a>, +} + +impl<'a> S3Options<'a> { + fn new(raw: &'a ToolVersionOptions) -> Self { + Self { + values: BackendOptions::new(raw), + } + } + + fn raw(&self) -> &'a ToolVersionOptions { + self.values.raw() + } + + fn url(&self) -> Option { + self.values.platform_string("url") + } + + fn checksum(&self) -> Option { + self.values.platform_string("checksum") + } + + fn bin_path(&self) -> Option { + self.values.platform_string("bin_path") + } + + fn region(&self) -> Option { + self.values.platform_string("region") + } + + fn endpoint(&self) -> Option { + self.values.platform_string("endpoint") + } + + fn version_list_url(&self) -> Option { + self.values.platform_string("version_list_url") + } + + fn version_prefix(&self) -> Option { + self.values.platform_string("version_prefix") + } + + fn version_regex(&self) -> Option { + self.values.platform_string("version_regex") + } + + fn version_json_path(&self) -> Option { + self.values.platform_string("version_json_path") + } + + fn version_expr(&self) -> Option { + self.values.platform_string("version_expr") + } +} + impl S3Backend { pub fn from_arg(ba: BackendArg) -> Self { Self { @@ -108,24 +166,19 @@ impl S3Backend { } /// Get or create the S3 client - async fn get_client(&self, opts: &ToolVersionOptions) -> Result<&S3Client> { + async fn get_client(&self, opts: &S3Options<'_>) -> Result<&S3Client> { self.client .get_or_try_init(|| async { - let region = lookup_with_fallback(opts, "region"); - let endpoint = lookup_with_fallback(opts, "endpoint"); + let region = opts.region(); + let endpoint = opts.endpoint(); create_s3_client(region.as_deref(), endpoint.as_deref()).await }) .await } - /// Get option value with platform-specific fallback - fn get_opt(opts: &ToolVersionOptions, key: &str) -> Option { - lookup_with_fallback(opts, key) - } - /// Resolve the download URL from options and version - fn resolve_url(&self, tv: &ToolVersion, opts: &ToolVersionOptions) -> Result { - let url_template = Self::get_opt(opts, "url").ok_or_else(|| { + fn resolve_url(&self, tv: &ToolVersion, opts: &S3Options<'_>) -> Result { + let url_template = opts.url().ok_or_else(|| { eyre!( "S3 backend requires 'url' option. Example: url = \"s3://bucket/tool-{{version}}.tar.gz\"" ) @@ -188,7 +241,7 @@ impl S3Backend { &self, client: &S3Client, manifest_url: &str, - opts: &ToolVersionOptions, + opts: &S3Options<'_>, ) -> Result> { let s3_url = S3Url::parse(manifest_url)?; @@ -200,9 +253,9 @@ impl S3Backend { // Read and parse the manifest let content = file::read_to_string(&tmp_path)?; - let regex = Self::get_opt(opts, "version_regex"); - let json_path = Self::get_opt(opts, "version_json_path"); - let version_expr = Self::get_opt(opts, "version_expr"); + let regex = opts.version_regex(); + let json_path = opts.version_json_path(); + let version_expr = opts.version_expr(); version_list::parse_version_list( &content, @@ -269,10 +322,11 @@ impl S3Backend { /// Fetch versions using the configured method (manifest or listing) async fn fetch_versions(&self, config: &Arc) -> Result> { - let opts = config.get_tool_opts_with_overrides(&self.ba).await?; + let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?; + let opts = S3Options::new(&raw_opts); // Try manifest-based version discovery first - if let Some(manifest_url) = Self::get_opt(&opts, "version_list_url") { + if let Some(manifest_url) = opts.version_list_url() { let client = self.get_client(&opts).await?; return self .fetch_versions_from_manifest(client, &manifest_url, &opts) @@ -280,12 +334,14 @@ impl S3Backend { } // Try S3 listing-based version discovery - if let Some(version_prefix) = Self::get_opt(&opts, "version_prefix") { - let version_regex = Self::get_opt(&opts, "version_regex") + if let Some(version_prefix) = opts.version_prefix() { + let version_regex = opts + .version_regex() .unwrap_or_else(|| r"([0-9]+\.[0-9]+\.[0-9]+)".to_string()); // Extract bucket from url option - let url_template = Self::get_opt(&opts, "url") + let url_template = opts + .url() .ok_or_else(|| eyre!("S3 backend requires 'url' option for version listing"))?; let s3_url = S3Url::parse(&url_template)?; @@ -436,12 +492,9 @@ impl Backend for S3Backend { } async fn install_operation_count(&self, tv: &ToolVersion, _ctx: &InstallContext) -> usize { - let opts = tv.request.options(); - super::http_install_operation_count( - Self::get_opt(&opts, "checksum").is_some(), - &self.get_platform_key(), - tv, - ) + let raw_opts = tv.request.options(); + let opts = S3Options::new(&raw_opts); + super::http_install_operation_count(opts.checksum().is_some(), &self.get_platform_key(), tv) } async fn _list_remote_versions(&self, config: &Arc) -> Result> { @@ -461,7 +514,8 @@ impl Backend for S3Backend { mut tv: ToolVersion, ) -> Result { Settings::get().ensure_experimental("s3 backend")?; - let opts = tv.request.options(); + let raw_opts = tv.request.options(); + let opts = S3Options::new(&raw_opts); // Resolve URL template let url = self.resolve_url(&tv, &opts)?; @@ -497,10 +551,10 @@ impl Backend for S3Backend { .await?; // Verify artifact (checksum/size from options) - if Self::get_opt(&opts, "checksum").is_some() { + if opts.checksum().is_some() { ctx.pr.next_operation(); } - verify_artifact(&tv, &file_path, &opts, Some(ctx.pr.as_ref()))?; + verify_artifact(&tv, &file_path, opts.raw(), Some(ctx.pr.as_ref()))?; // Verify/generate lockfile checksum (before extraction for security) if lockfile_enabled || has_lockfile_checksum { @@ -511,7 +565,7 @@ impl Backend for S3Backend { // Extract and install ctx.pr.next_operation(); ctx.pr.set_message("extract".into()); - install_artifact(&tv, &file_path, &opts, Some(ctx.pr.as_ref()))?; + install_artifact(&tv, &file_path, opts.raw(), Some(ctx.pr.as_ref()))?; Ok(tv) } @@ -521,10 +575,11 @@ impl Backend for S3Backend { _config: &Arc, tv: &ToolVersion, ) -> Result> { - let opts = tv.request.options(); + let raw_opts = tv.request.options(); + let opts = S3Options::new(&raw_opts); // Check for explicit bin_path - if let Some(bin_path_template) = lookup_with_fallback(&opts, "bin_path") { + if let Some(bin_path_template) = opts.bin_path() { let bin_path = template_string(&bin_path_template, tv); return Ok(vec![runtime_path_for_install_path( tv, diff --git a/src/backend/ubi.rs b/src/backend/ubi.rs index c879d7857a..b6ffc09371 100644 --- a/src/backend/ubi.rs +++ b/src/backend/ubi.rs @@ -1,8 +1,9 @@ use crate::backend::VersionInfo; use crate::backend::backend_type::BackendType; +use crate::backend::options::{BackendOptions, is_truthy}; use crate::backend::platform_target::PlatformTarget; use crate::backend::runtime_path_for_install_path; -use crate::backend::static_helpers::{lookup_platform_key, try_with_v_prefix}; +use crate::backend::static_helpers::try_with_v_prefix; use crate::cli::args::BackendArg; use crate::config::{Config, Settings}; use crate::env::{ @@ -30,6 +31,81 @@ pub struct UbiBackend { ba: Arc, } +#[derive(Debug, Clone, Copy)] +struct UbiOptions<'a> { + values: BackendOptions<'a>, +} + +impl<'a> UbiOptions<'a> { + fn new(raw: &'a ToolVersionOptions) -> Self { + Self { + values: BackendOptions::new(raw), + } + } + + fn provider(&self) -> eyre::Result { + match self.values.str("provider") { + Some(forge) => Ok(ForgeType::from_str(forge)?), + None => Ok(ForgeType::default()), + } + } + + fn api_url_override(&self) -> Option<&'a str> { + self.values.str("api_url") + } + + fn api_url(&self, forge: &ForgeType) -> eyre::Result { + match self.api_url_override() { + Some(api_url) => Ok(api_url.strip_suffix('/').unwrap_or(api_url).to_string()), + None => match forge { + ForgeType::GitHub => Ok(github::API_URL.to_string()), + ForgeType::GitLab => Ok(gitlab::API_URL.to_string()), + _ => bail!("Unsupported forge type {:?}", forge), + }, + } + } + + fn tag_regex(&self) -> Option<&'a str> { + self.values.str("tag_regex") + } + + fn bin_path(&self) -> Option { + self.values.platform_string("bin_path") + } + + fn extract_all(&self) -> bool { + self.values + .string("extract_all") + .is_some_and(|v| is_truthy(&v)) + } + + fn exe(&self) -> Option<&'a str> { + self.values.str("exe") + } + + fn rename_exe(&self) -> Option<&'a str> { + self.values.str("rename_exe") + } + + fn matching(&self) -> Option<&'a str> { + self.values.str("matching") + } + + fn matching_regex(&self) -> Option<&'a str> { + self.values.str("matching_regex") + } + + fn lockfile_options(&self) -> BTreeMap { + let mut result = BTreeMap::new(); + for key in ["exe", "matching", "matching_regex", "provider"] { + if let Some(value) = self.values.str(key) { + result.insert(key.to_string(), value.to_string()); + } + } + result + } +} + #[async_trait] impl Backend for UbiBackend { fn get_type(&self) -> BackendType { @@ -61,19 +137,11 @@ impl Backend for UbiBackend { ..Default::default() }]) } else { - let opts = config.get_tool_opts_with_overrides(&self.ba).await?; - let forge = match opts.get("provider") { - Some(forge) => ForgeType::from_str(forge)?, - None => ForgeType::default(), - }; - let api_url = match opts.get("api_url") { - Some(api_url) => api_url.strip_suffix("/").unwrap_or(api_url), - None => match forge { - ForgeType::GitHub => github::API_URL, - ForgeType::GitLab => gitlab::API_URL, - _ => bail!("Unsupported forge type {:?}", forge), - }, - }; + let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?; + let opts = UbiOptions::new(&raw_opts); + let forge = opts.provider()?; + let api_url = opts.api_url(&forge)?; + let tag_regex = opts.tag_regex(); let tag_regex_cell = OnceLock::new(); @@ -102,7 +170,7 @@ impl Backend for UbiBackend { // Helper to check if tag matches tag_regex (if provided) let matches_tag_regex = |tag: &str| -> bool { - if let Some(re_str) = opts.get("tag_regex") { + if let Some(re_str) = tag_regex { let re = tag_regex_cell.get_or_init(|| Regex::new(re_str).unwrap()); re.is_match(tag) } else { @@ -122,10 +190,10 @@ impl Backend for UbiBackend { let mut version_infos: Vec = match forge { ForgeType::GitHub => { let releases = - github::list_releases_from_url(api_url, &self.tool_name()).await?; + github::list_releases_from_url(&api_url, &self.tool_name()).await?; if releases.is_empty() { // Fall back to tags (no created_at available) - github::list_tags_from_url(api_url, &self.tool_name()) + github::list_tags_from_url(&api_url, &self.tool_name()) .await? .into_iter() .filter(|tag| matches_tag_regex(tag)) @@ -158,10 +226,10 @@ impl Backend for UbiBackend { } ForgeType::GitLab => { let releases = - gitlab::list_releases_from_url(api_url, &self.tool_name()).await?; + gitlab::list_releases_from_url(&api_url, &self.tool_name()).await?; if releases.is_empty() { // Fall back to tags (no created_at available) - gitlab::list_tags_from_url(api_url, &self.tool_name()) + gitlab::list_tags_from_url(&api_url, &self.tool_name()) .await? .into_iter() .filter(|tag| matches_tag_regex(tag)) @@ -222,11 +290,10 @@ impl Backend for UbiBackend { .and_then(|p| p.url.clone()); let v = tv.version.to_string(); - let opts = tv.request.options(); - let bin_path = lookup_platform_key(&opts, "bin_path") - .or_else(|| opts.get("bin_path").map(|s| s.to_string())) - .unwrap_or_else(|| "bin".to_string()); - let extract_all = opts.get("extract_all").is_some_and(|v| v == "true"); + let raw_opts = tv.request.options(); + let opts = UbiOptions::new(&raw_opts); + let bin_path = opts.bin_path().unwrap_or_else(|| "bin".to_string()); + let extract_all = opts.extract_all(); let bin_dir = tv.install_path(); // Use lockfile URL if available, otherwise fall back to standard resolution @@ -236,7 +303,7 @@ impl Backend for UbiBackend { install(&self.tool_name(), &v, &bin_dir, extract_all, &opts).await?; } else { try_with_v_prefix(&v, None, |candidate| { - let opts = opts.clone(); + let opts = opts; let bin_dir = bin_dir.clone(); async move { install( @@ -253,10 +320,8 @@ impl Backend for UbiBackend { } let mut possible_exes = vec![ - tv.request - .options() - .get("exe") - .map(|s| s.to_string()) + opts.exe() + .map(str::to_string) .unwrap_or(tv.ba().short.to_string()), ]; if cfg!(windows) { @@ -323,11 +388,13 @@ impl Backend for UbiBackend { // For ubi backend, generate a more specific platform key that includes tool-specific options let mut platform_key = self.get_platform_key(); let filename = file.file_name().unwrap().to_string_lossy().to_string(); + let raw_opts = tv.request.options(); + let opts = UbiOptions::new(&raw_opts); - if let Some(exe) = tv.request.options().get("exe") { + if let Some(exe) = opts.exe() { platform_key = format!("{platform_key}-{exe}"); } - if let Some(matching) = tv.request.options().get("matching") { + if let Some(matching) = opts.matching() { platform_key = format!("{platform_key}-{matching}"); } // Include filename to distinguish different downloads for the same platform @@ -358,16 +425,15 @@ impl Backend for UbiBackend { _config: &Arc, tv: &ToolVersion, ) -> eyre::Result> { - let opts = tv.request.options(); - if let Some(bin_path) = lookup_platform_key(&opts, "bin_path") - .or_else(|| opts.get("bin_path").map(|s| s.to_string())) - { + let raw_opts = tv.request.options(); + let opts = UbiOptions::new(&raw_opts); + if let Some(bin_path) = opts.bin_path() { // bin_path should always point to a directory containing binaries Ok(vec![runtime_path_for_install_path( tv, tv.install_path().join(&bin_path), )]) - } else if opts.get("extract_all").is_some_and(|v| v == "true") { + } else if opts.extract_all() { Ok(vec![tv.runtime_path()]) } else { let bin_path = tv.install_path().join("bin"); @@ -390,17 +456,8 @@ impl Backend for UbiBackend { request: &ToolRequest, _target: &PlatformTarget, ) -> BTreeMap { - let opts = request.options(); - let mut result = BTreeMap::new(); - - // These options affect which artifact is downloaded - for key in ["exe", "matching", "matching_regex", "provider"] { - if let Some(value) = opts.get(key) { - result.insert(key.to_string(), value.to_string()); - } - } - - result + let raw_opts = request.options(); + UbiOptions::new(&raw_opts).lockfile_options() } } @@ -465,7 +522,7 @@ async fn install( v: &str, bin_dir: &Path, extract_all: bool, - opts: &ToolVersionOptions, + opts: &UbiOptions<'_>, ) -> eyre::Result<()> { let mut builder = UbiBuilder::new().install_dir(bin_dir); @@ -479,28 +536,25 @@ async fn install( if extract_all { builder = builder.extract_all(); } else { - if let Some(exe) = opts.get("exe") { + if let Some(exe) = opts.exe() { builder = builder.exe(exe); } - if let Some(rename_exe) = opts.get("rename_exe") { + if let Some(rename_exe) = opts.rename_exe() { builder = builder.rename_exe_to(rename_exe) } } - if let Some(matching) = opts.get("matching") { + if let Some(matching) = opts.matching() { builder = builder.matching(matching); } - if let Some(matching_regex) = opts.get("matching_regex") { + if let Some(matching_regex) = opts.matching_regex() { builder = builder.matching_regex(matching_regex); } - let forge = match opts.get("provider") { - Some(forge) => ForgeType::from_str(forge)?, - None => ForgeType::default(), - }; + let forge = opts.provider()?; builder = builder.forge(forge.clone()); builder = set_token(builder, &forge); - if let Some(api_url) = opts.get("api_url") + if let Some(api_url) = opts.api_url_override() && !api_url.contains("github.com") && !api_url.contains("gitlab.com") { From e395b097257d300bf93c12a462c99ce7b956d5db Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Thu, 14 May 2026 01:08:39 +1000 Subject: [PATCH 2/5] refactor(backend): remove redundant ubi option binding --- src/backend/ubi.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/ubi.rs b/src/backend/ubi.rs index b6ffc09371..7b46f0ba1e 100644 --- a/src/backend/ubi.rs +++ b/src/backend/ubi.rs @@ -303,7 +303,6 @@ impl Backend for UbiBackend { install(&self.tool_name(), &v, &bin_dir, extract_all, &opts).await?; } else { try_with_v_prefix(&v, None, |candidate| { - let opts = opts; let bin_dir = bin_dir.clone(); async move { install( From 0f2dde5e6cdee0f8e6c7dd64fe292a16bb7f51c0 Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Thu, 14 May 2026 10:14:42 +1000 Subject: [PATCH 3/5] refactor(backend): address option review feedback --- src/backend/github.rs | 6 ++---- src/backend/options.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/backend/github.rs b/src/backend/github.rs index f77dec225e..adb850d6fa 100644 --- a/src/backend/github.rs +++ b/src/backend/github.rs @@ -1,7 +1,7 @@ use crate::backend::VersionInfo; use crate::backend::asset_matcher::{self, Asset, AssetPicker, ChecksumFetcher}; use crate::backend::backend_type::BackendType; -use crate::backend::options::{BackendOptions, is_truthy}; +use crate::backend::options::BackendOptions; use crate::backend::platform_target::PlatformTarget; use crate::backend::static_helpers::{ get_filename_from_url, install_artifact, template_string, try_with_v_prefix, @@ -89,9 +89,7 @@ impl<'a> GitBackendOptions<'a> { } fn no_app(&self) -> bool { - self.values - .string("no_app") - .is_some_and(|value| is_truthy(&value)) + self.values.platform_bool("no_app") } fn filter_bins(&self) -> Option> { diff --git a/src/backend/options.rs b/src/backend/options.rs index a80c885d05..43d342bfab 100644 --- a/src/backend/options.rs +++ b/src/backend/options.rs @@ -18,10 +18,14 @@ impl<'a> BackendOptions<'a> { self.raw } + /// Returns the option as an owned `String`, coercing scalar TOML values to + /// their string representation. pub(crate) fn string(&self, key: &str) -> Option { self.raw.get_string(key) } + /// Returns the option only when the underlying TOML value is a string. + /// Prefer `string()` for options that may be written as native TOML scalars. pub(crate) fn str(&self, key: &str) -> Option<&'a str> { self.raw.get(key) } @@ -50,6 +54,10 @@ impl<'a> BackendOptions<'a> { self.string(key).is_some_and(|v| is_truthy(&v)) } + pub(crate) fn platform_bool(&self, key: &str) -> bool { + self.platform_string(key).is_some_and(|v| is_truthy(&v)) + } + pub(crate) fn available_platforms_with_key(&self, key: &str) -> Vec { list_available_platforms_with_key(self.raw, key) } From d81db51a401e7728c6c6391b4002c7d7753d69ba Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Thu, 14 May 2026 12:49:33 +1000 Subject: [PATCH 4/5] fix(backend): respect target-specific no_app option --- src/backend/github.rs | 10 +++++----- src/backend/options.rs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/backend/github.rs b/src/backend/github.rs index adb850d6fa..d72f55c114 100644 --- a/src/backend/github.rs +++ b/src/backend/github.rs @@ -88,8 +88,8 @@ impl<'a> GitBackendOptions<'a> { .platform_string_for_target_without_base("url", target) } - fn no_app(&self) -> bool { - self.values.platform_bool("no_app") + fn no_app_for_target(&self, target: &PlatformTarget) -> bool { + self.values.platform_bool_for_target("no_app", target) } fn filter_bins(&self) -> Option> { @@ -1137,7 +1137,7 @@ impl UnifiedGitBackend { // Fall back to auto-detection for target platform let asset_name = asset_matcher::AssetMatcher::new() .for_target(target) - .with_no_app(opts.no_app()) + .with_no_app(opts.no_app_for_target(target)) .pick_from(&available_assets)? .name; let asset = self @@ -1226,7 +1226,7 @@ impl UnifiedGitBackend { // Fall back to auto-detection for target platform let asset_name = asset_matcher::AssetMatcher::new() .for_target(target) - .with_no_app(opts.no_app()) + .with_no_app(opts.no_app_for_target(target)) .pick_from(&available_assets)? .name; let asset = self @@ -1314,7 +1314,7 @@ impl UnifiedGitBackend { // Fall back to auto-detection for target platform let asset_name = asset_matcher::AssetMatcher::new() .for_target(target) - .with_no_app(opts.no_app()) + .with_no_app(opts.no_app_for_target(target)) .pick_from(&available_assets)? .name; let asset = self diff --git a/src/backend/options.rs b/src/backend/options.rs index 43d342bfab..369667a994 100644 --- a/src/backend/options.rs +++ b/src/backend/options.rs @@ -54,8 +54,9 @@ impl<'a> BackendOptions<'a> { self.string(key).is_some_and(|v| is_truthy(&v)) } - pub(crate) fn platform_bool(&self, key: &str) -> bool { - self.platform_string(key).is_some_and(|v| is_truthy(&v)) + pub(crate) fn platform_bool_for_target(&self, key: &str, target: &PlatformTarget) -> bool { + self.platform_string_for_target(key, target) + .is_some_and(|v| is_truthy(&v)) } pub(crate) fn available_platforms_with_key(&self, key: &str) -> Vec { @@ -66,3 +67,30 @@ impl<'a> BackendOptions<'a> { pub(crate) fn is_truthy(value: &str) -> bool { matches!(value.trim(), "true" | "1") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::Platform; + + #[test] + fn test_platform_bool_for_target_uses_requested_target() { + let mut opts = ToolVersionOptions::default(); + let mut platforms = toml::Table::new(); + let mut linux = toml::Table::new(); + let mut windows = toml::Table::new(); + linux.insert("no_app".into(), toml::Value::Boolean(false)); + windows.insert("no_app".into(), toml::Value::Boolean(true)); + platforms.insert("linux-x64".into(), toml::Value::Table(linux)); + platforms.insert("windows-x64".into(), toml::Value::Table(windows)); + opts.opts + .insert("platforms".into(), toml::Value::Table(platforms)); + + let values = BackendOptions::new(&opts); + let linux = PlatformTarget::new(Platform::parse("linux-x64").unwrap()); + let windows = PlatformTarget::new(Platform::parse("windows-x64").unwrap()); + + assert!(!values.platform_bool_for_target("no_app", &linux)); + assert!(values.platform_bool_for_target("no_app", &windows)); + } +} From fda4e0227075a1d5e79fc8b8be356c3ba9318c82 Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Fri, 15 May 2026 06:26:40 +1000 Subject: [PATCH 5/5] refactor(backend): split non-git option parsers --- src/backend/aqua.rs | 149 ++++++++++++++++-------------------- src/backend/http.rs | 153 ++++++++++++------------------------- src/backend/options.rs | 21 +----- src/backend/s3.rs | 119 ++++++++--------------------- src/backend/ubi.rs | 167 ++++++++++++++--------------------------- 5 files changed, 201 insertions(+), 408 deletions(-) diff --git a/src/backend/aqua.rs b/src/backend/aqua.rs index 3ad1f80040..015d6ca324 100644 --- a/src/backend/aqua.rs +++ b/src/backend/aqua.rs @@ -1,6 +1,5 @@ use crate::backend::VersionInfo; use crate::backend::backend_type::BackendType; -use crate::backend::options::BackendOptions; use crate::backend::platform_target::PlatformTarget; use crate::backend::static_helpers::get_filename_from_url; @@ -48,66 +47,6 @@ pub struct AquaBackend { version_tags_cache: CacheManager>, } -#[derive(Debug, Clone, Copy)] -struct AquaOptions<'a> { - values: BackendOptions<'a>, -} - -impl<'a> AquaOptions<'a> { - fn new(raw: &'a ToolVersionOptions) -> Self { - Self { - values: BackendOptions::new(raw), - } - } - - fn symlink_bins(&self) -> bool { - self.values.bool("symlink_bins") - } - - fn var(&self, name: &str) -> Result> { - let opts = self.values.raw(); - if let Some(toml::Value::Table(vars)) = opts.opts.get("vars") - && let Some(value) = vars.get(name) - { - return toml_string_var(&format!("vars.{name}"), value).map(Some); - } - opts.opts - .get(name) - .map(|value| toml_string_var(name, value).map(Some)) - .unwrap_or(Ok(None)) - } - - fn lockfile_options(&self) -> BTreeMap { - let mut result = BTreeMap::new(); - for (key, value) in self.values.raw().iter() { - if key == "symlink_bins" || EPHEMERAL_OPT_KEYS.contains(&key.as_str()) { - continue; - } - if key == "vars" { - if let toml::Value::Table(table) = value { - Self::insert_vars_lockfile_options(&mut result, table); - } - } else if let Some(value) = toml_value_to_string(value) { - let key = if key.starts_with("vars.") { - key.clone() - } else { - format!("vars.{key}") - }; - result.entry(key).or_insert(value); - } - } - result - } - - fn insert_vars_lockfile_options(result: &mut BTreeMap, table: &toml::Table) { - for (key, value) in table { - if let Some(value) = toml_value_to_string(value) { - result.insert(format!("vars.{key}"), value); - } - } - } -} - #[derive(Debug, Clone, PartialEq, Eq)] struct AquaFileLink { src: PathBuf, @@ -563,9 +502,7 @@ impl Backend for AquaBackend { ) -> Result> { let runtime_path = tv.runtime_path(); let mise_bins_dir = tv.install_path().join(MISE_BINS_DIR); - let request_options = tv.request.options(); - let opts = AquaOptions::new(&request_options); - if opts.symlink_bins() || mise_bins_dir.is_dir() { + if self.symlink_bins(tv) || mise_bins_dir.is_dir() { return Ok(vec![runtime_path.join(MISE_BINS_DIR)]); } @@ -584,7 +521,8 @@ impl Backend for AquaBackend { }); } - let cache_key = opts.lockfile_options(); + let request_options = tv.request.options(); + let cache_key = Self::lockfile_options(&request_options); let cache: CacheManager> = CacheManagerBuilder::new(tv.cache_path().join("bin_paths.msgpack.z")) .with_fresh_file(install_path.clone()) @@ -623,8 +561,7 @@ impl Backend for AquaBackend { request: &ToolRequest, _target: &PlatformTarget, ) -> BTreeMap { - let request_options = request.options(); - AquaOptions::new(&request_options).lockfile_options() + Self::lockfile_options(&request.options()) } fn fuzzy_match_filter( @@ -695,8 +632,7 @@ impl Backend for AquaBackend { // Using package_with_version() here would apply overrides for the current host // platform first, which can leak host-specific overrides into cross-platform lock. let pkg = AQUA_REGISTRY.package(&self.id).await?; - let raw_opts = tv.request.options(); - let opts = AquaOptions::new(&raw_opts); + let opts = tv.request.options(); let target_libc = Self::target_variant_libc(target); let pkg = pkg.with_version_libc(&versions, target_os, target_arch, target_libc.as_deref()); let pkg = Self::apply_aqua_libc_replacement(pkg, target_os, Self::target_libc(target)); @@ -883,9 +819,7 @@ impl AquaBackend { let target_libc = Self::target_variant_libc(&target); let pkg = pkg.with_version_libc(versions, target_os, target_arch, target_libc.as_deref()); let pkg = Self::apply_aqua_libc_replacement(pkg, target_os, Self::target_libc(&target)); - let raw_opts = tv.request.options(); - let opts = AquaOptions::new(&raw_opts); - Self::apply_var_options(pkg, &opts) + Self::apply_var_options(pkg, &tv.request.options()) } fn to_aqua_platform(target: &PlatformTarget) -> (&str, &str) { @@ -953,19 +887,49 @@ impl AquaBackend { pkg } - fn apply_var_options(pkg: AquaPackage, opts: &AquaOptions<'_>) -> Result { + fn apply_var_options(pkg: AquaPackage, opts: &ToolVersionOptions) -> Result { if pkg.vars.is_empty() { return Ok(pkg); } let mut var_values = HashMap::new(); for var in &pkg.vars { - if let Some(value) = opts.var(&var.name)? { + if let Some(value) = aqua_var_option(opts, &var.name)? { var_values.insert(var.name.clone(), value); } } pkg.with_var_values(var_values) } + fn lockfile_options(opts: &ToolVersionOptions) -> BTreeMap { + let mut result = BTreeMap::new(); + for (key, value) in opts.iter() { + if key == "symlink_bins" || EPHEMERAL_OPT_KEYS.contains(&key.as_str()) { + continue; + } + if key == "vars" { + if let toml::Value::Table(table) = value { + Self::insert_vars_lockfile_options(&mut result, table); + } + } else if let Some(value) = toml_value_to_string(value) { + let key = if key.starts_with("vars.") { + key.clone() + } else { + format!("vars.{key}") + }; + result.entry(key).or_insert(value); + } + } + result + } + + fn insert_vars_lockfile_options(result: &mut BTreeMap, table: &toml::Table) { + for (key, value) in table { + if let Some(value) = toml_value_to_string(value) { + result.insert(format!("vars.{key}"), value); + } + } + } + fn has_native_cosign(cosign: &AquaCosign) -> bool { cosign.enabled != Some(false) && (cosign.key.is_some() || cosign.bundle.is_some()) } @@ -2287,9 +2251,7 @@ impl AquaBackend { } } - let raw_opts = tv.request.options(); - let opts = AquaOptions::new(&raw_opts); - if opts.symlink_bins() { + if self.symlink_bins(tv) { self.create_symlink_bin_dir(tv, &srcs)?; } @@ -2313,6 +2275,13 @@ impl AquaBackend { Ok(()) } + fn symlink_bins(&self, tv: &ToolVersion) -> bool { + tv.request + .options() + .get_string("symlink_bins") + .is_some_and(|v| v == "true" || v == "1") + } + fn srcs(pkg: &AquaPackage, tv: &ToolVersion) -> Result> { Self::srcs_for_platform(pkg, &tv.version, &tv.install_path(), os(), arch()) } @@ -2509,6 +2478,18 @@ fn toml_value_to_string(value: &toml::Value) -> Option { } } +fn aqua_var_option(opts: &ToolVersionOptions, name: &str) -> Result> { + if let Some(toml::Value::Table(vars)) = opts.opts.get("vars") + && let Some(value) = vars.get(name) + { + return toml_string_var(&format!("vars.{name}"), value).map(Some); + } + opts.opts + .get(name) + .map(|value| toml_string_var(name, value).map(Some)) + .unwrap_or(Ok(None)) +} + fn toml_string_var(key: &str, value: &toml::Value) -> Result { match value { toml::Value::String(s) => Ok(s.clone()), @@ -2698,7 +2679,6 @@ mod tests { opts.opts .insert("vars".to_string(), toml::Value::Table(vars)); - let opts = AquaOptions::new(&opts); let pkg = AquaBackend::apply_var_options(pkg, &opts).unwrap(); assert_eq!( @@ -2720,7 +2700,6 @@ mod tests { opts.opts .insert("vars".to_string(), toml::Value::Table(vars)); - let opts = AquaOptions::new(&opts); let err = AquaBackend::apply_var_options(pkg, &opts).unwrap_err(); assert!( @@ -2734,9 +2713,7 @@ mod tests { fn test_apply_var_options_errors_for_missing_required_var() { let mut pkg = AquaPackage::default(); pkg.vars = vec![aqua_var("go_version", true)]; - let opts = ToolVersionOptions::default(); - let opts = AquaOptions::new(&opts); - let err = AquaBackend::apply_var_options(pkg, &opts).unwrap_err(); + let err = AquaBackend::apply_var_options(pkg, &ToolVersionOptions::default()).unwrap_err(); assert!( err.to_string() @@ -2765,7 +2742,7 @@ mod tests { opts.opts .insert("vars".to_string(), toml::Value::Table(vars)); - let lock_opts = AquaOptions::new(&opts).lockfile_options(); + let lock_opts = AquaBackend::lockfile_options(&opts); assert_eq!(lock_opts.get("vars.channel"), Some(&"stable".to_string())); assert_eq!(lock_opts.get("vars.go_version"), Some(&"1.24".to_string())); @@ -2792,8 +2769,8 @@ mod tests { .insert("vars".to_string(), toml::Value::Table(vars)); assert_eq!( - AquaOptions::new(&top_level).lockfile_options(), - AquaOptions::new(&nested).lockfile_options() + AquaBackend::lockfile_options(&top_level), + AquaBackend::lockfile_options(&nested) ); } @@ -2812,7 +2789,7 @@ mod tests { opts.opts .insert("vars".to_string(), toml::Value::Table(vars)); - let lock_opts = AquaOptions::new(&opts).lockfile_options(); + let lock_opts = AquaBackend::lockfile_options(&opts); assert_eq!(lock_opts.get("vars.channel"), Some(&"beta".to_string())); } diff --git a/src/backend/http.rs b/src/backend/http.rs index 4e4e972888..1449a46081 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -1,11 +1,10 @@ use crate::backend::Backend; use crate::backend::VersionInfo; use crate::backend::backend_type::BackendType; -use crate::backend::options::BackendOptions; use crate::backend::runtime_path_for_install_path; use crate::backend::static_helpers::{ - clean_binary_name, get_filename_from_url, rename_executable_in_dir, template_string, - verify_artifact, + clean_binary_name, get_filename_from_url, list_available_platforms_with_key, + lookup_platform_key, rename_executable_in_dir, template_string, verify_artifact, }; use crate::backend::version_list; use crate::cli::args::BackendArg; @@ -29,6 +28,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; const HTTP_TARBALLS_DIR: &str = "http-tarballs"; const METADATA_FILE: &str = "metadata.json"; +/// Helper to get an option value with platform-specific fallback +fn get_opt(opts: &ToolVersionOptions, key: &str) -> Option { + lookup_platform_key(opts, key).or_else(|| opts.get_string(key)) +} + /// Metadata stored alongside cached extractions #[derive(Debug, Serialize, Deserialize)] struct CacheMetadata { @@ -62,9 +66,9 @@ struct FileInfo { impl FileInfo { /// Analyze a file path and options to determine format information - fn new(file_path: &Path, opts: &HttpOptions<'_>) -> Self { + fn new(file_path: &Path, opts: &ToolVersionOptions) -> Self { // Apply format config to determine effective extension - let effective_path = if let Some(added_ext) = opts.format() { + let effective_path = if let Some(added_ext) = get_opt(opts, "format") { let mut path = file_path.to_path_buf(); let current_ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let new_ext = if current_ext.is_empty() { @@ -124,71 +128,6 @@ pub struct HttpBackend { ba: Arc, } -#[derive(Debug, Clone, Copy)] -struct HttpOptions<'a> { - values: BackendOptions<'a>, -} - -impl<'a> HttpOptions<'a> { - fn new(raw: &'a ToolVersionOptions) -> Self { - Self { - values: BackendOptions::new(raw), - } - } - - fn raw(&self) -> &'a ToolVersionOptions { - self.values.raw() - } - - fn url(&self) -> Option { - self.values.platform_string("url") - } - - fn checksum(&self) -> Option { - self.values.platform_string("checksum") - } - - fn format(&self) -> Option { - self.values.platform_string("format") - } - - fn strip_components(&self) -> Option { - self.values.platform_string("strip_components") - } - - fn bin(&self) -> Option { - self.values.platform_string("bin") - } - - fn rename_exe(&self) -> Option { - self.values.platform_string("rename_exe") - } - - fn bin_path(&self) -> Option { - self.values.platform_string("bin_path") - } - - fn version_list_url(&self) -> Option<&'a str> { - self.values.str("version_list_url") - } - - fn version_regex(&self) -> Option<&'a str> { - self.values.str("version_regex") - } - - fn version_json_path(&self) -> Option<&'a str> { - self.values.str("version_json_path") - } - - fn version_expr(&self) -> Option<&'a str> { - self.values.str("version_expr") - } - - fn url_platforms(&self) -> Vec { - self.values.available_platforms_with_key("url") - } -} - impl HttpBackend { pub fn from_arg(ba: BackendArg) -> Self { Self { ba: Arc::new(ba) } @@ -223,23 +162,23 @@ impl HttpBackend { // ------------------------------------------------------------------------- /// Generate a cache key based on file content and extraction options - fn cache_key(&self, file_path: &Path, opts: &HttpOptions<'_>) -> Result { + fn cache_key(&self, file_path: &Path, opts: &ToolVersionOptions) -> Result { let checksum = hash::file_hash_blake3(file_path, None)?; // Include extraction options that affect output structure // Note: bin_path is NOT included - handled at symlink time for deduplication let mut parts = vec![checksum]; - if let Some(strip) = opts.strip_components() { + if let Some(strip) = get_opt(opts, "strip_components") { parts.push(format!("strip_{strip}")); } // Include rename_exe in cache key since it modifies the extracted content - if let Some(rename) = opts.rename_exe() { + if let Some(rename) = get_opt(opts, "rename_exe") { parts.push(format!("rename_{rename}")); // When rename_exe is used, bin_path affects where the rename happens, // so different bin_path values result in different cached content - if let Some(bin_path) = opts.bin_path() { + if let Some(bin_path) = get_opt(opts, "bin_path") { parts.push(format!("binpath_{bin_path}")); } } @@ -258,10 +197,10 @@ impl HttpBackend { &self, file_path: &Path, file_info: &FileInfo, - opts: &HttpOptions<'_>, + opts: &ToolVersionOptions, ) -> String { // Check for explicit bin name first - if let Some(bin_name) = opts.bin() { + if let Some(bin_name) = get_opt(opts, "bin") { return bin_name; } @@ -316,7 +255,7 @@ impl HttpBackend { file_path: &Path, cache_key: &str, url: &str, - opts: &HttpOptions<'_>, + opts: &ToolVersionOptions, pr: Option<&dyn SingleReport>, ) -> Result { let cache_path = self.cache_path(cache_key); @@ -358,7 +297,7 @@ impl HttpBackend { tv: &ToolVersion, dest: &Path, file_path: &Path, - opts: &HttpOptions<'_>, + opts: &ToolVersionOptions, pr: Option<&dyn SingleReport>, ) -> Result { file::create_dir_all(dest)?; @@ -380,7 +319,7 @@ impl HttpBackend { dest: &Path, file_path: &Path, file_info: &FileInfo, - opts: &HttpOptions<'_>, + opts: &ToolVersionOptions, pr: Option<&dyn SingleReport>, ) -> Result { let filename = self.dest_filename(file_path, file_info, opts); @@ -410,7 +349,7 @@ impl HttpBackend { dest: &Path, file_path: &Path, file_info: &FileInfo, - opts: &HttpOptions<'_>, + opts: &ToolVersionOptions, pr: Option<&dyn SingleReport>, ) -> Result { let filename = self.dest_filename(file_path, file_info, opts); @@ -434,15 +373,15 @@ impl HttpBackend { dest: &Path, file_path: &Path, file_info: &FileInfo, - opts: &HttpOptions<'_>, + opts: &ToolVersionOptions, pr: Option<&dyn SingleReport>, ) -> Result { let mut strip_components: Option = - opts.strip_components().and_then(|s| s.parse().ok()); + get_opt(opts, "strip_components").and_then(|s| s.parse().ok()); // Auto-detect strip_components=1 for single-directory archives if strip_components.is_none() - && opts.bin_path().is_none() + && get_opt(opts, "bin_path").is_none() && file::should_strip_components(file_path, file_info.format).unwrap_or(false) { debug!("Auto-detected single directory archive, using strip_components=1"); @@ -459,10 +398,10 @@ impl HttpBackend { file::untar(file_path, dest, &tar_opts)?; // Handle rename_exe option for archives - if let Some(rename_to) = opts.rename_exe() { + if let Some(rename_to) = get_opt(opts, "rename_exe") { // When bin_path is not explicitly set, auto-detect bin/ subdirectory to match // the same logic used by discover_bin_paths() for PATH construction - let search_dir = if let Some(bin_path_template) = opts.bin_path() { + let search_dir = if let Some(bin_path_template) = get_opt(opts, "bin_path") { let bin_path = template_string(&bin_path_template, tv); dest.join(&bin_path) } else { @@ -487,11 +426,11 @@ impl HttpBackend { cache_key: &str, url: &str, file_path: &Path, - opts: &HttpOptions<'_>, + opts: &ToolVersionOptions, ) -> Result<()> { let metadata = CacheMetadata { url: url.to_string(), - checksum: opts.checksum(), + checksum: get_opt(opts, "checksum"), size: file_path.metadata()?.len(), extracted_at: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), platform: self.get_platform_key(), @@ -512,7 +451,7 @@ impl HttpBackend { tv: &ToolVersion, cache_key: &str, extraction_type: &ExtractionType, - opts: &HttpOptions<'_>, + opts: &ToolVersionOptions, ) -> Result<()> { let cache_path = self.cache_path(cache_key); @@ -535,7 +474,7 @@ impl HttpBackend { // Handle raw files with bin_path specially for deduplication if let ExtractionType::RawFile { filename } = extraction_type - && let Some(bin_path_template) = opts.bin_path() + && let Some(bin_path_template) = get_opt(opts, "bin_path") { let bin_path = template_string(&bin_path_template, tv); let dest_dir = install_path.join(&bin_path); @@ -626,17 +565,16 @@ impl HttpBackend { /// Fetch versions from version_list_url if configured async fn fetch_versions(&self, config: &Arc) -> Result> { - let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?; - let opts = HttpOptions::new(&raw_opts); + let opts = config.get_tool_opts_with_overrides(&self.ba).await?; - let url = match opts.version_list_url() { + let url = match opts.get("version_list_url") { Some(url) => url.to_string(), None => return Ok(vec![]), }; - let regex = opts.version_regex(); - let json_path = opts.version_json_path(); - let version_expr = opts.version_expr(); + let regex = opts.get("version_regex"); + let json_path = opts.get("version_json_path"); + let version_expr = opts.get("version_expr"); version_list::fetch_versions(&url, regex, json_path, version_expr).await } @@ -680,9 +618,12 @@ impl Backend for HttpBackend { } async fn install_operation_count(&self, tv: &ToolVersion, _ctx: &InstallContext) -> usize { - let raw_opts = tv.request.options(); - let opts = HttpOptions::new(&raw_opts); - super::http_install_operation_count(opts.checksum().is_some(), &self.get_platform_key(), tv) + let opts = tv.request.options(); + super::http_install_operation_count( + get_opt(&opts, "checksum").is_some(), + &self.get_platform_key(), + tv, + ) } async fn _list_remote_versions(&self, config: &Arc) -> Result> { @@ -701,13 +642,12 @@ impl Backend for HttpBackend { ctx: &InstallContext, mut tv: ToolVersion, ) -> Result { - let raw_opts = tv.request.options(); - let opts = HttpOptions::new(&raw_opts); + let opts = tv.request.options(); // Get URL template - let url_template = opts.url().ok_or_else(|| { + let url_template = get_opt(&opts, "url").ok_or_else(|| { let platform_key = self.get_platform_key(); - let available = opts.url_platforms(); + let available = list_available_platforms_with_key(&opts, "url"); if !available.is_empty() { eyre::eyre!( "No URL for platform {platform_key}. Available: {}. \ @@ -746,10 +686,10 @@ impl Backend for HttpBackend { .await?; // Verify artifact (checksum if provided) - if opts.checksum().is_some() { + if get_opt(&opts, "checksum").is_some() { ctx.pr.next_operation(); } - verify_artifact(&tv, &file_path, opts.raw(), Some(ctx.pr.as_ref()))?; + verify_artifact(&tv, &file_path, &opts, Some(ctx.pr.as_ref()))?; // Generate cache key let cache_key = self.cache_key(&file_path, &opts)?; @@ -799,12 +739,11 @@ impl Backend for HttpBackend { _config: &Arc, tv: &ToolVersion, ) -> Result> { - let raw_opts = tv.request.options(); - let opts = HttpOptions::new(&raw_opts); + let opts = tv.request.options(); let install_path = tv.install_path(); // Check for explicit bin_path - if let Some(bin_path_template) = opts.bin_path() { + if let Some(bin_path_template) = get_opt(&opts, "bin_path") { let bin_path = template_string(&bin_path_template, tv); return Ok(vec![tv.runtime_path().join(bin_path)]); } diff --git a/src/backend/options.rs b/src/backend/options.rs index 369667a994..8e9706c006 100644 --- a/src/backend/options.rs +++ b/src/backend/options.rs @@ -1,7 +1,5 @@ use crate::backend::platform_target::PlatformTarget; -use crate::backend::static_helpers::{ - list_available_platforms_with_key, lookup_platform_key_for_target, lookup_with_fallback, -}; +use crate::backend::static_helpers::{lookup_platform_key_for_target, lookup_with_fallback}; use crate::toolset::ToolVersionOptions; #[derive(Debug, Clone, Copy)] @@ -18,14 +16,9 @@ impl<'a> BackendOptions<'a> { self.raw } - /// Returns the option as an owned `String`, coercing scalar TOML values to - /// their string representation. - pub(crate) fn string(&self, key: &str) -> Option { - self.raw.get_string(key) - } - /// Returns the option only when the underlying TOML value is a string. - /// Prefer `string()` for options that may be written as native TOML scalars. + /// Prefer platform helpers for options that may be written as native TOML + /// scalars. pub(crate) fn str(&self, key: &str) -> Option<&'a str> { self.raw.get(key) } @@ -50,18 +43,10 @@ impl<'a> BackendOptions<'a> { lookup_platform_key_for_target(self.raw, key, target) } - pub(crate) fn bool(&self, key: &str) -> bool { - self.string(key).is_some_and(|v| is_truthy(&v)) - } - pub(crate) fn platform_bool_for_target(&self, key: &str, target: &PlatformTarget) -> bool { self.platform_string_for_target(key, target) .is_some_and(|v| is_truthy(&v)) } - - pub(crate) fn available_platforms_with_key(&self, key: &str) -> Vec { - list_available_platforms_with_key(self.raw, key) - } } pub(crate) fn is_truthy(value: &str) -> bool { diff --git a/src/backend/s3.rs b/src/backend/s3.rs index 05dff5cd9a..003e1197d9 100644 --- a/src/backend/s3.rs +++ b/src/backend/s3.rs @@ -38,9 +38,8 @@ pub const EXPERIMENTAL: bool = true; use crate::backend::backend_type::BackendType; -use crate::backend::options::BackendOptions; use crate::backend::static_helpers::{ - get_filename_from_url, install_artifact, template_string, verify_artifact, + get_filename_from_url, install_artifact, lookup_with_fallback, template_string, verify_artifact, }; use crate::backend::version_list; use crate::backend::{Backend, VersionInfo, runtime_path_for_install_path}; @@ -100,63 +99,6 @@ pub struct S3Backend { client: OnceCell, } -#[derive(Debug, Clone, Copy)] -struct S3Options<'a> { - values: BackendOptions<'a>, -} - -impl<'a> S3Options<'a> { - fn new(raw: &'a ToolVersionOptions) -> Self { - Self { - values: BackendOptions::new(raw), - } - } - - fn raw(&self) -> &'a ToolVersionOptions { - self.values.raw() - } - - fn url(&self) -> Option { - self.values.platform_string("url") - } - - fn checksum(&self) -> Option { - self.values.platform_string("checksum") - } - - fn bin_path(&self) -> Option { - self.values.platform_string("bin_path") - } - - fn region(&self) -> Option { - self.values.platform_string("region") - } - - fn endpoint(&self) -> Option { - self.values.platform_string("endpoint") - } - - fn version_list_url(&self) -> Option { - self.values.platform_string("version_list_url") - } - - fn version_prefix(&self) -> Option { - self.values.platform_string("version_prefix") - } - - fn version_regex(&self) -> Option { - self.values.platform_string("version_regex") - } - - fn version_json_path(&self) -> Option { - self.values.platform_string("version_json_path") - } - - fn version_expr(&self) -> Option { - self.values.platform_string("version_expr") - } -} - impl S3Backend { pub fn from_arg(ba: BackendArg) -> Self { Self { @@ -166,19 +108,24 @@ impl S3Backend { } /// Get or create the S3 client - async fn get_client(&self, opts: &S3Options<'_>) -> Result<&S3Client> { + async fn get_client(&self, opts: &ToolVersionOptions) -> Result<&S3Client> { self.client .get_or_try_init(|| async { - let region = opts.region(); - let endpoint = opts.endpoint(); + let region = lookup_with_fallback(opts, "region"); + let endpoint = lookup_with_fallback(opts, "endpoint"); create_s3_client(region.as_deref(), endpoint.as_deref()).await }) .await } + /// Get option value with platform-specific fallback + fn get_opt(opts: &ToolVersionOptions, key: &str) -> Option { + lookup_with_fallback(opts, key) + } + /// Resolve the download URL from options and version - fn resolve_url(&self, tv: &ToolVersion, opts: &S3Options<'_>) -> Result { - let url_template = opts.url().ok_or_else(|| { + fn resolve_url(&self, tv: &ToolVersion, opts: &ToolVersionOptions) -> Result { + let url_template = Self::get_opt(opts, "url").ok_or_else(|| { eyre!( "S3 backend requires 'url' option. Example: url = \"s3://bucket/tool-{{version}}.tar.gz\"" ) @@ -241,7 +188,7 @@ impl S3Backend { &self, client: &S3Client, manifest_url: &str, - opts: &S3Options<'_>, + opts: &ToolVersionOptions, ) -> Result> { let s3_url = S3Url::parse(manifest_url)?; @@ -253,9 +200,9 @@ impl S3Backend { // Read and parse the manifest let content = file::read_to_string(&tmp_path)?; - let regex = opts.version_regex(); - let json_path = opts.version_json_path(); - let version_expr = opts.version_expr(); + let regex = Self::get_opt(opts, "version_regex"); + let json_path = Self::get_opt(opts, "version_json_path"); + let version_expr = Self::get_opt(opts, "version_expr"); version_list::parse_version_list( &content, @@ -322,11 +269,10 @@ impl S3Backend { /// Fetch versions using the configured method (manifest or listing) async fn fetch_versions(&self, config: &Arc) -> Result> { - let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?; - let opts = S3Options::new(&raw_opts); + let opts = config.get_tool_opts_with_overrides(&self.ba).await?; // Try manifest-based version discovery first - if let Some(manifest_url) = opts.version_list_url() { + if let Some(manifest_url) = Self::get_opt(&opts, "version_list_url") { let client = self.get_client(&opts).await?; return self .fetch_versions_from_manifest(client, &manifest_url, &opts) @@ -334,14 +280,12 @@ impl S3Backend { } // Try S3 listing-based version discovery - if let Some(version_prefix) = opts.version_prefix() { - let version_regex = opts - .version_regex() + if let Some(version_prefix) = Self::get_opt(&opts, "version_prefix") { + let version_regex = Self::get_opt(&opts, "version_regex") .unwrap_or_else(|| r"([0-9]+\.[0-9]+\.[0-9]+)".to_string()); // Extract bucket from url option - let url_template = opts - .url() + let url_template = Self::get_opt(&opts, "url") .ok_or_else(|| eyre!("S3 backend requires 'url' option for version listing"))?; let s3_url = S3Url::parse(&url_template)?; @@ -492,9 +436,12 @@ impl Backend for S3Backend { } async fn install_operation_count(&self, tv: &ToolVersion, _ctx: &InstallContext) -> usize { - let raw_opts = tv.request.options(); - let opts = S3Options::new(&raw_opts); - super::http_install_operation_count(opts.checksum().is_some(), &self.get_platform_key(), tv) + let opts = tv.request.options(); + super::http_install_operation_count( + Self::get_opt(&opts, "checksum").is_some(), + &self.get_platform_key(), + tv, + ) } async fn _list_remote_versions(&self, config: &Arc) -> Result> { @@ -514,8 +461,7 @@ impl Backend for S3Backend { mut tv: ToolVersion, ) -> Result { Settings::get().ensure_experimental("s3 backend")?; - let raw_opts = tv.request.options(); - let opts = S3Options::new(&raw_opts); + let opts = tv.request.options(); // Resolve URL template let url = self.resolve_url(&tv, &opts)?; @@ -551,10 +497,10 @@ impl Backend for S3Backend { .await?; // Verify artifact (checksum/size from options) - if opts.checksum().is_some() { + if Self::get_opt(&opts, "checksum").is_some() { ctx.pr.next_operation(); } - verify_artifact(&tv, &file_path, opts.raw(), Some(ctx.pr.as_ref()))?; + verify_artifact(&tv, &file_path, &opts, Some(ctx.pr.as_ref()))?; // Verify/generate lockfile checksum (before extraction for security) if lockfile_enabled || has_lockfile_checksum { @@ -565,7 +511,7 @@ impl Backend for S3Backend { // Extract and install ctx.pr.next_operation(); ctx.pr.set_message("extract".into()); - install_artifact(&tv, &file_path, opts.raw(), Some(ctx.pr.as_ref()))?; + install_artifact(&tv, &file_path, &opts, Some(ctx.pr.as_ref()))?; Ok(tv) } @@ -575,11 +521,10 @@ impl Backend for S3Backend { _config: &Arc, tv: &ToolVersion, ) -> Result> { - let raw_opts = tv.request.options(); - let opts = S3Options::new(&raw_opts); + let opts = tv.request.options(); // Check for explicit bin_path - if let Some(bin_path_template) = opts.bin_path() { + if let Some(bin_path_template) = lookup_with_fallback(&opts, "bin_path") { let bin_path = template_string(&bin_path_template, tv); return Ok(vec![runtime_path_for_install_path( tv, diff --git a/src/backend/ubi.rs b/src/backend/ubi.rs index 7b46f0ba1e..c879d7857a 100644 --- a/src/backend/ubi.rs +++ b/src/backend/ubi.rs @@ -1,9 +1,8 @@ use crate::backend::VersionInfo; use crate::backend::backend_type::BackendType; -use crate::backend::options::{BackendOptions, is_truthy}; use crate::backend::platform_target::PlatformTarget; use crate::backend::runtime_path_for_install_path; -use crate::backend::static_helpers::try_with_v_prefix; +use crate::backend::static_helpers::{lookup_platform_key, try_with_v_prefix}; use crate::cli::args::BackendArg; use crate::config::{Config, Settings}; use crate::env::{ @@ -31,81 +30,6 @@ pub struct UbiBackend { ba: Arc, } -#[derive(Debug, Clone, Copy)] -struct UbiOptions<'a> { - values: BackendOptions<'a>, -} - -impl<'a> UbiOptions<'a> { - fn new(raw: &'a ToolVersionOptions) -> Self { - Self { - values: BackendOptions::new(raw), - } - } - - fn provider(&self) -> eyre::Result { - match self.values.str("provider") { - Some(forge) => Ok(ForgeType::from_str(forge)?), - None => Ok(ForgeType::default()), - } - } - - fn api_url_override(&self) -> Option<&'a str> { - self.values.str("api_url") - } - - fn api_url(&self, forge: &ForgeType) -> eyre::Result { - match self.api_url_override() { - Some(api_url) => Ok(api_url.strip_suffix('/').unwrap_or(api_url).to_string()), - None => match forge { - ForgeType::GitHub => Ok(github::API_URL.to_string()), - ForgeType::GitLab => Ok(gitlab::API_URL.to_string()), - _ => bail!("Unsupported forge type {:?}", forge), - }, - } - } - - fn tag_regex(&self) -> Option<&'a str> { - self.values.str("tag_regex") - } - - fn bin_path(&self) -> Option { - self.values.platform_string("bin_path") - } - - fn extract_all(&self) -> bool { - self.values - .string("extract_all") - .is_some_and(|v| is_truthy(&v)) - } - - fn exe(&self) -> Option<&'a str> { - self.values.str("exe") - } - - fn rename_exe(&self) -> Option<&'a str> { - self.values.str("rename_exe") - } - - fn matching(&self) -> Option<&'a str> { - self.values.str("matching") - } - - fn matching_regex(&self) -> Option<&'a str> { - self.values.str("matching_regex") - } - - fn lockfile_options(&self) -> BTreeMap { - let mut result = BTreeMap::new(); - for key in ["exe", "matching", "matching_regex", "provider"] { - if let Some(value) = self.values.str(key) { - result.insert(key.to_string(), value.to_string()); - } - } - result - } -} - #[async_trait] impl Backend for UbiBackend { fn get_type(&self) -> BackendType { @@ -137,11 +61,19 @@ impl Backend for UbiBackend { ..Default::default() }]) } else { - let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?; - let opts = UbiOptions::new(&raw_opts); - let forge = opts.provider()?; - let api_url = opts.api_url(&forge)?; - let tag_regex = opts.tag_regex(); + let opts = config.get_tool_opts_with_overrides(&self.ba).await?; + let forge = match opts.get("provider") { + Some(forge) => ForgeType::from_str(forge)?, + None => ForgeType::default(), + }; + let api_url = match opts.get("api_url") { + Some(api_url) => api_url.strip_suffix("/").unwrap_or(api_url), + None => match forge { + ForgeType::GitHub => github::API_URL, + ForgeType::GitLab => gitlab::API_URL, + _ => bail!("Unsupported forge type {:?}", forge), + }, + }; let tag_regex_cell = OnceLock::new(); @@ -170,7 +102,7 @@ impl Backend for UbiBackend { // Helper to check if tag matches tag_regex (if provided) let matches_tag_regex = |tag: &str| -> bool { - if let Some(re_str) = tag_regex { + if let Some(re_str) = opts.get("tag_regex") { let re = tag_regex_cell.get_or_init(|| Regex::new(re_str).unwrap()); re.is_match(tag) } else { @@ -190,10 +122,10 @@ impl Backend for UbiBackend { let mut version_infos: Vec = match forge { ForgeType::GitHub => { let releases = - github::list_releases_from_url(&api_url, &self.tool_name()).await?; + github::list_releases_from_url(api_url, &self.tool_name()).await?; if releases.is_empty() { // Fall back to tags (no created_at available) - github::list_tags_from_url(&api_url, &self.tool_name()) + github::list_tags_from_url(api_url, &self.tool_name()) .await? .into_iter() .filter(|tag| matches_tag_regex(tag)) @@ -226,10 +158,10 @@ impl Backend for UbiBackend { } ForgeType::GitLab => { let releases = - gitlab::list_releases_from_url(&api_url, &self.tool_name()).await?; + gitlab::list_releases_from_url(api_url, &self.tool_name()).await?; if releases.is_empty() { // Fall back to tags (no created_at available) - gitlab::list_tags_from_url(&api_url, &self.tool_name()) + gitlab::list_tags_from_url(api_url, &self.tool_name()) .await? .into_iter() .filter(|tag| matches_tag_regex(tag)) @@ -290,10 +222,11 @@ impl Backend for UbiBackend { .and_then(|p| p.url.clone()); let v = tv.version.to_string(); - let raw_opts = tv.request.options(); - let opts = UbiOptions::new(&raw_opts); - let bin_path = opts.bin_path().unwrap_or_else(|| "bin".to_string()); - let extract_all = opts.extract_all(); + let opts = tv.request.options(); + let bin_path = lookup_platform_key(&opts, "bin_path") + .or_else(|| opts.get("bin_path").map(|s| s.to_string())) + .unwrap_or_else(|| "bin".to_string()); + let extract_all = opts.get("extract_all").is_some_and(|v| v == "true"); let bin_dir = tv.install_path(); // Use lockfile URL if available, otherwise fall back to standard resolution @@ -303,6 +236,7 @@ impl Backend for UbiBackend { install(&self.tool_name(), &v, &bin_dir, extract_all, &opts).await?; } else { try_with_v_prefix(&v, None, |candidate| { + let opts = opts.clone(); let bin_dir = bin_dir.clone(); async move { install( @@ -319,8 +253,10 @@ impl Backend for UbiBackend { } let mut possible_exes = vec![ - opts.exe() - .map(str::to_string) + tv.request + .options() + .get("exe") + .map(|s| s.to_string()) .unwrap_or(tv.ba().short.to_string()), ]; if cfg!(windows) { @@ -387,13 +323,11 @@ impl Backend for UbiBackend { // For ubi backend, generate a more specific platform key that includes tool-specific options let mut platform_key = self.get_platform_key(); let filename = file.file_name().unwrap().to_string_lossy().to_string(); - let raw_opts = tv.request.options(); - let opts = UbiOptions::new(&raw_opts); - if let Some(exe) = opts.exe() { + if let Some(exe) = tv.request.options().get("exe") { platform_key = format!("{platform_key}-{exe}"); } - if let Some(matching) = opts.matching() { + if let Some(matching) = tv.request.options().get("matching") { platform_key = format!("{platform_key}-{matching}"); } // Include filename to distinguish different downloads for the same platform @@ -424,15 +358,16 @@ impl Backend for UbiBackend { _config: &Arc, tv: &ToolVersion, ) -> eyre::Result> { - let raw_opts = tv.request.options(); - let opts = UbiOptions::new(&raw_opts); - if let Some(bin_path) = opts.bin_path() { + let opts = tv.request.options(); + if let Some(bin_path) = lookup_platform_key(&opts, "bin_path") + .or_else(|| opts.get("bin_path").map(|s| s.to_string())) + { // bin_path should always point to a directory containing binaries Ok(vec![runtime_path_for_install_path( tv, tv.install_path().join(&bin_path), )]) - } else if opts.extract_all() { + } else if opts.get("extract_all").is_some_and(|v| v == "true") { Ok(vec![tv.runtime_path()]) } else { let bin_path = tv.install_path().join("bin"); @@ -455,8 +390,17 @@ impl Backend for UbiBackend { request: &ToolRequest, _target: &PlatformTarget, ) -> BTreeMap { - let raw_opts = request.options(); - UbiOptions::new(&raw_opts).lockfile_options() + let opts = request.options(); + let mut result = BTreeMap::new(); + + // These options affect which artifact is downloaded + for key in ["exe", "matching", "matching_regex", "provider"] { + if let Some(value) = opts.get(key) { + result.insert(key.to_string(), value.to_string()); + } + } + + result } } @@ -521,7 +465,7 @@ async fn install( v: &str, bin_dir: &Path, extract_all: bool, - opts: &UbiOptions<'_>, + opts: &ToolVersionOptions, ) -> eyre::Result<()> { let mut builder = UbiBuilder::new().install_dir(bin_dir); @@ -535,25 +479,28 @@ async fn install( if extract_all { builder = builder.extract_all(); } else { - if let Some(exe) = opts.exe() { + if let Some(exe) = opts.get("exe") { builder = builder.exe(exe); } - if let Some(rename_exe) = opts.rename_exe() { + if let Some(rename_exe) = opts.get("rename_exe") { builder = builder.rename_exe_to(rename_exe) } } - if let Some(matching) = opts.matching() { + if let Some(matching) = opts.get("matching") { builder = builder.matching(matching); } - if let Some(matching_regex) = opts.matching_regex() { + if let Some(matching_regex) = opts.get("matching_regex") { builder = builder.matching_regex(matching_regex); } - let forge = opts.provider()?; + let forge = match opts.get("provider") { + Some(forge) => ForgeType::from_str(forge)?, + None => ForgeType::default(), + }; builder = builder.forge(forge.clone()); builder = set_token(builder, &forge); - if let Some(api_url) = opts.api_url_override() + if let Some(api_url) = opts.get("api_url") && !api_url.contains("github.com") && !api_url.contains("gitlab.com") {