diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 60cac5c9c2..a1300b2db0 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -765,10 +765,22 @@ mod tests { opts.opts.insert( "prerelease".to_string(), - toml::Value::String("false".into()), + toml::Value::String("FALSE".into()), ); assert!(!backend.include_prereleases(&opts)); + opts.opts + .insert("prerelease".to_string(), toml::Value::String("1".into())); + assert!(backend.include_prereleases(&opts)); + + opts.opts + .insert("prerelease".to_string(), toml::Value::String("0".into())); + assert!(!backend.include_prereleases(&opts)); + + opts.opts + .insert("prerelease".to_string(), toml::Value::String("00".into())); + assert!(!backend.include_prereleases(&opts)); + // Defense-in-depth: also accept a native TOML boolean, in case a future // config path stores the value without string normalization. opts.opts @@ -2881,11 +2893,7 @@ pub(crate) fn mark_prerelease(mut version: VersionInfo) -> VersionInfo { } fn tool_option_bool(value: &toml::Value) -> bool { - match value { - toml::Value::Boolean(b) => *b, - toml::Value::String(s) => s.parse::().unwrap_or(false), - _ => false, - } + crate::backend::options::bool_value_or_default("prerelease", value, false) } /// Fuzzy-match `versions` against `query` with PEP 440 prerelease detection diff --git a/src/backend/options.rs b/src/backend/options.rs index 0265298b00..561cedb6db 100644 --- a/src/backend/options.rs +++ b/src/backend/options.rs @@ -47,11 +47,18 @@ impl<'a> BackendOptions<'a> { 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)) + .is_some_and(|v| bool_str_or_default(key, &v, false)) } pub(crate) fn bool(&self, key: &str) -> bool { - self.raw.get_string(key).is_some_and(|v| is_truthy(&v)) + self.bool_with_default(key, false) + } + + pub(crate) fn bool_with_default(&self, key: &str, default: bool) -> bool { + self.raw + .opts + .get(key) + .map_or(default, |value| bool_value_or_default(key, value, default)) } pub(crate) fn available_platforms_with_key(&self, key: &str) -> Vec { @@ -60,7 +67,52 @@ impl<'a> BackendOptions<'a> { } pub(crate) fn is_truthy(value: &str) -> bool { - matches!(value.trim(), "true" | "1") + matches!(value.trim().to_ascii_lowercase().as_str(), "true" | "1") +} + +pub(crate) fn is_falsey(value: &str) -> bool { + matches!(value.trim().to_ascii_lowercase().as_str(), "false" | "0") +} + +pub(crate) fn bool_value_or_default(key: &str, value: &toml::Value, default: bool) -> bool { + bool_value(key, value).unwrap_or(default) +} + +pub(crate) fn bool_value(key: &str, value: &toml::Value) -> Option { + let parsed = match value { + toml::Value::Boolean(value) => Some(*value), + toml::Value::String(value) => parse_bool_str(value), + toml::Value::Integer(0) => Some(false), + toml::Value::Integer(1) => Some(true), + _ => None, + }; + if parsed.is_none() { + warn_invalid_bool_value(key, value); + } + parsed +} + +fn bool_str_or_default(key: &str, value: &str, default: bool) -> bool { + parse_bool_str(value).unwrap_or_else(|| { + warn_invalid_bool_value(key, value); + default + }) +} + +fn parse_bool_str(value: &str) -> Option { + if is_truthy(value) { + Some(true) + } else if is_falsey(value) { + Some(false) + } else { + None + } +} + +fn warn_invalid_bool_value(key: &str, value: impl std::fmt::Display) { + warn!( + "invalid boolean value for tool option `{key}`: {value}; expected true, false, 1, or 0; using default" + ); } #[cfg(test)] @@ -68,6 +120,66 @@ mod tests { use super::*; use crate::platform::Platform; + fn opts_with_value(key: &str, value: toml::Value) -> ToolVersionOptions { + let mut opts = ToolVersionOptions::default(); + opts.opts.insert(key.to_string(), value); + opts + } + + #[test] + fn test_bool_parses_consistent_formats() { + assert!( + BackendOptions::new(&opts_with_value("flag", toml::Value::Boolean(true))).bool("flag") + ); + assert!( + !BackendOptions::new(&opts_with_value("flag", toml::Value::Boolean(false))) + .bool("flag") + ); + assert!( + BackendOptions::new(&opts_with_value("flag", toml::Value::String("TRUE".into()))) + .bool("flag") + ); + assert!( + !BackendOptions::new(&opts_with_value( + "flag", + toml::Value::String("FALSE".into()) + )) + .bool("flag") + ); + assert!( + BackendOptions::new(&opts_with_value("flag", toml::Value::String("1".into()))) + .bool("flag") + ); + assert!( + !BackendOptions::new(&opts_with_value("flag", toml::Value::String("0".into()))) + .bool("flag") + ); + assert!( + BackendOptions::new(&opts_with_value("flag", toml::Value::Integer(1))).bool("flag") + ); + assert!( + !BackendOptions::new(&opts_with_value("flag", toml::Value::Integer(0))).bool("flag") + ); + } + + #[test] + fn test_bool_invalid_values_fall_back_to_default() { + assert!(!BackendOptions::new(&ToolVersionOptions::default()).bool("missing")); + assert!( + !BackendOptions::new(&opts_with_value("flag", toml::Value::String("00".into()))) + .bool("flag") + ); + assert!( + BackendOptions::new(&opts_with_value("flag", toml::Value::String("00".into()))) + .bool_with_default("flag", true) + ); + assert!( + BackendOptions::new(&opts_with_value("flag", toml::Value::Integer(2))) + .bool_with_default("flag", true) + ); + assert_eq!(bool_value("flag", &toml::Value::String("00".into())), None); + } + #[test] fn test_platform_bool_for_target_uses_requested_target() { let mut opts = ToolVersionOptions::default(); diff --git a/src/plugins/core/dotnet.rs b/src/plugins/core/dotnet.rs index fa900b6292..2f9a1d7611 100644 --- a/src/plugins/core/dotnet.rs +++ b/src/plugins/core/dotnet.rs @@ -9,13 +9,14 @@ use versions::Versioning; use crate::backend::Backend; use crate::backend::VersionInfo; +use crate::backend::options::BackendOptions; use crate::cli::args::BackendArg; use crate::cmd::CmdLineRunner; use crate::config::{Config, Settings}; use crate::http::{HTTP, HTTP_FETCH}; use crate::install_context::InstallContext; use crate::parallel; -use crate::toolset::{ToolVersion, Toolset}; +use crate::toolset::{ToolVersion, ToolVersionOptions, Toolset}; use crate::ui::progress_report::SingleReport; use crate::{dirs, env, file, plugins}; @@ -24,6 +25,27 @@ pub struct DotnetPlugin { ba: Arc, } +#[derive(Debug, Clone, Copy)] +struct DotnetOptions<'a> { + values: BackendOptions<'a>, +} + +impl<'a> DotnetOptions<'a> { + fn new(raw: &'a ToolVersionOptions) -> Self { + Self { + values: BackendOptions::new(raw), + } + } + + fn runtime(&self) -> Option<&'a str> { + self.values.str("runtime") + } + + fn runtime_framework_name(&self) -> Option<&'static str> { + self.runtime().and_then(runtime_framework_name) + } +} + impl DotnetPlugin { pub fn new() -> Self { Self { @@ -36,7 +58,9 @@ impl DotnetPlugin { } async fn test_dotnet(&self, ctx: &InstallContext, tv: &ToolVersion) -> Result<()> { - if tv.request.options().get("runtime").is_some() { + let raw_opts = tv.request.options(); + let opts = DotnetOptions::new(&raw_opts); + if opts.runtime().is_some() { // Skip version check for runtime-only installs — `dotnet --version` exits non-zero without an SDK return Ok(()); } @@ -126,9 +150,11 @@ impl Backend for DotnetPlugin { file::create_dir_all(&install_dir)?; // Read and validate runtime options - let runtime = tv.request.options().get("runtime").map(|s| s.to_string()); + let raw_opts = tv.request.options(); + let opts = DotnetOptions::new(&raw_opts); + let runtime = opts.runtime().map(str::to_string); if let Some(ref rt) = runtime - && runtime_framework_name(rt).is_none() + && opts.runtime_framework_name().is_none() { return Err(eyre::eyre!( "Invalid runtime option '{}'. Valid options: dotnet, aspnetcore, windowsdesktop", @@ -176,10 +202,11 @@ impl Backend for DotnetPlugin { if Self::is_isolated() { // Isolated: mise handles removal of install_path by default } else { - let runtime = tv.request.options().get("runtime").map(|s| s.to_string()); - if let Some(rt) = runtime { + let raw_opts = tv.request.options(); + let opts = DotnetOptions::new(&raw_opts); + if opts.runtime().is_some() { // Runtime: remove the shared runtime directory for this version - let Some(framework) = runtime_framework_name(&rt) else { + let Some(framework) = opts.runtime_framework_name() else { return Ok(()); }; let runtime_dir = dotnet_root() @@ -386,3 +413,29 @@ impl PartialOrd for SortedVersion { Some(self.cmp(other)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn opts_with_runtime(runtime: &str) -> ToolVersionOptions { + let mut opts = ToolVersionOptions::default(); + opts.opts.insert( + "runtime".to_string(), + toml::Value::String(runtime.to_string()), + ); + opts + } + + #[test] + fn dotnet_options_reads_runtime() { + let opts = opts_with_runtime("aspnetcore"); + let parsed = DotnetOptions::new(&opts); + + assert_eq!(parsed.runtime(), Some("aspnetcore")); + assert_eq!( + parsed.runtime_framework_name(), + Some("Microsoft.AspNetCore.App") + ); + } +} diff --git a/src/plugins/core/java.rs b/src/plugins/core/java.rs index 3d289e0b66..0e2f220209 100644 --- a/src/plugins/core/java.rs +++ b/src/plugins/core/java.rs @@ -4,6 +4,7 @@ use std::fs::{self}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use crate::backend::options::BackendOptions; use crate::backend::{ Backend, VersionInfo, normalize_idiomatic_contents, platform_target::PlatformTarget, }; @@ -17,7 +18,7 @@ use crate::http::{HTTP, HTTP_FETCH}; use crate::install_context::InstallContext; use crate::lockfile::PlatformInfo; use crate::platform::Platform; -use crate::toolset::{ToolRequest, ToolVersion, Toolset}; +use crate::toolset::{ToolRequest, ToolVersion, ToolVersionOptions, Toolset}; use crate::ui::progress_report::SingleReport; use crate::{file, plugins}; use async_trait::async_trait; @@ -47,6 +48,32 @@ pub struct JavaPlugin { tokio::sync::Mutex>>, } +#[derive(Debug, Clone, Copy)] +struct JavaOptions<'a> { + values: BackendOptions<'a>, +} + +impl<'a> JavaOptions<'a> { + fn new(raw: &'a ToolVersionOptions) -> Self { + Self { + values: BackendOptions::new(raw), + } + } + + fn release_type(&self) -> &'a str { + self.values.str("release_type").unwrap_or("ga") + } + + fn lockfile_options(&self) -> BTreeMap { + let mut opts = BTreeMap::new(); + let release_type = self.release_type(); + if release_type != "ga" { + opts.insert("release_type".to_string(), release_type.to_string()); + } + opts + } +} + impl JavaPlugin { pub fn new() -> Self { let settings = Settings::get(); @@ -297,11 +324,8 @@ impl JavaPlugin { } fn tv_release_type(&self, tv: &ToolVersion) -> String { - tv.request - .options() - .get("release_type") - .map(|s| s.to_string()) - .unwrap_or(String::from("ga")) + let raw_opts = tv.request.options(); + JavaOptions::new(&raw_opts).release_type().to_string() } fn tv_to_java_version(&self, tv: &ToolVersion) -> String { @@ -361,11 +385,8 @@ impl Backend for JavaPlugin { } async fn _list_remote_versions(&self, config: &Arc) -> Result> { - let opts = config.get_tool_opts_with_overrides(&self.ba).await?; - let release_type = opts - .get("release_type") - .map(|s| s.to_string()) - .unwrap_or_else(|| "ga".to_string()); + let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?; + let release_type = JavaOptions::new(&raw_opts).release_type().to_string(); let versions = self .fetch_java_metadata(&release_type) @@ -458,14 +479,8 @@ impl Backend for JavaPlugin { request: &ToolRequest, _target: &PlatformTarget, ) -> BTreeMap { - let mut opts = BTreeMap::new(); - if let Some(release_type) = request.options().get("release_type") { - let release_type = release_type.to_string(); - if release_type != "ga" { - opts.insert("release_type".to_string(), release_type); - } - } - opts + let raw_opts = request.options(); + JavaOptions::new(&raw_opts).lockfile_options() } async fn resolve_lock_info( @@ -763,3 +778,35 @@ impl JavaMetadata { static JAVA_FEATURES: Lazy> = Lazy::new(|| { HashSet::from(["crac", "javafx", "jcef", "leyden", "lite", "musl"].map(|s| s.to_string())) }); + +#[cfg(test)] +mod tests { + use super::*; + + fn opts_with_release_type(release_type: &str) -> ToolVersionOptions { + let mut opts = ToolVersionOptions::default(); + opts.opts.insert( + "release_type".to_string(), + toml::Value::String(release_type.to_string()), + ); + opts + } + + #[test] + fn java_options_reads_release_type() { + let default_opts = ToolVersionOptions::default(); + assert_eq!(JavaOptions::new(&default_opts).release_type(), "ga"); + assert!( + JavaOptions::new(&default_opts) + .lockfile_options() + .is_empty() + ); + + let opts = opts_with_release_type("ea"); + assert_eq!(JavaOptions::new(&opts).release_type(), "ea"); + assert_eq!( + JavaOptions::new(&opts).lockfile_options(), + BTreeMap::from([("release_type".to_string(), "ea".to_string())]) + ); + } +} diff --git a/src/plugins/core/python.rs b/src/plugins/core/python.rs index cd22f63d5e..57bfe1a984 100644 --- a/src/plugins/core/python.rs +++ b/src/plugins/core/python.rs @@ -1,3 +1,4 @@ +use crate::backend::options::BackendOptions; use crate::backend::platform_target::PlatformTarget; use crate::backend::static_helpers::fetch_checksum_from_shasums; use crate::backend::{Backend, VersionCacheManager, VersionInfo}; @@ -12,7 +13,7 @@ use crate::http::{HTTP, HTTP_FETCH}; use crate::install_context::InstallContext; use crate::lockfile::{PlatformInfo, ProvenanceType}; use crate::platform::Platform; -use crate::toolset::{ToolRequest, ToolVersion, Toolset}; +use crate::toolset::{ToolRequest, ToolVersion, ToolVersionOptions, Toolset}; use crate::ui::progress_report::SingleReport; use crate::{Result, lock_file::LockFile}; use crate::{dirs, file, plugins, sysconfig}; @@ -37,6 +38,27 @@ pub struct PythonPlugin { ba: Arc, } +#[derive(Debug, Clone, Copy)] +struct PythonOptions<'a> { + values: BackendOptions<'a>, +} + +impl<'a> PythonOptions<'a> { + fn new(raw: &'a ToolVersionOptions) -> Self { + Self { + values: BackendOptions::new(raw), + } + } + + fn patch_sysconfig(&self) -> bool { + self.values.bool_with_default("patch_sysconfig", true) + } + + fn virtualenv(&self) -> Option<&'a str> { + self.values.str("virtualenv") + } +} + pub fn python_path(tv: &ToolVersion) -> PathBuf { if cfg!(windows) { tv.install_path().join("python.exe") @@ -354,7 +376,9 @@ impl PythonPlugin { .map(|s| re_digits.replace(s, "").to_string()); if cfg!(unix) { if let (Some(major), Some(minor), Some(suffix)) = (major, minor, suffix) { - if tv.request.options().get("patch_sysconfig") != Some("false") { + let raw_opts = tv.request.options(); + let opts = PythonOptions::new(&raw_opts); + if opts.patch_sysconfig() { sysconfig::update_sysconfig(&install, major, minor, &suffix)?; } } else { @@ -447,7 +471,9 @@ impl PythonPlugin { config: &Arc, tv: &ToolVersion, ) -> eyre::Result> { - if let Some(virtualenv) = tv.request.options().get("virtualenv") { + let raw_opts = tv.request.options(); + let opts = PythonOptions::new(&raw_opts); + if let Some(virtualenv) = opts.virtualenv() { if !Settings::get().experimental { warn!( "please enable experimental mode with `mise settings experimental=true` \ @@ -1043,6 +1069,39 @@ fn filter_freethreaded(v: &str, flavor: &Option) -> bool { mod tests { use super::*; + fn opts_with(key: &str, value: &str) -> ToolVersionOptions { + opts_with_value(key, toml::Value::String(value.to_string())) + } + + fn opts_with_value(key: &str, value: toml::Value) -> ToolVersionOptions { + let mut opts = ToolVersionOptions::default(); + opts.opts.insert(key.to_string(), value); + opts + } + + #[test] + fn python_options_reads_patch_sysconfig() { + assert!(PythonOptions::new(&ToolVersionOptions::default()).patch_sysconfig()); + assert!(!PythonOptions::new(&opts_with("patch_sysconfig", "false")).patch_sysconfig()); + assert!(!PythonOptions::new(&opts_with("patch_sysconfig", "FALSE")).patch_sysconfig()); + assert!(!PythonOptions::new(&opts_with("patch_sysconfig", "0")).patch_sysconfig()); + assert!( + !PythonOptions::new(&opts_with_value( + "patch_sysconfig", + toml::Value::Boolean(false) + )) + .patch_sysconfig() + ); + assert!(PythonOptions::new(&opts_with("patch_sysconfig", "1")).patch_sysconfig()); + assert!(PythonOptions::new(&opts_with("patch_sysconfig", "00")).patch_sysconfig()); + } + + #[test] + fn python_options_reads_virtualenv() { + let opts = opts_with("virtualenv", ".venv"); + assert_eq!(PythonOptions::new(&opts).virtualenv(), Some(".venv")); + } + #[test] fn test_resolve_python_arch_windows_x64() { assert_eq!(resolve_python_arch("windows", "x64"), "x86_64"); diff --git a/src/plugins/core/rust.rs b/src/plugins/core/rust.rs index 98c56c531d..57fd6ad9e8 100644 --- a/src/plugins/core/rust.rs +++ b/src/plugins/core/rust.rs @@ -3,6 +3,7 @@ use std::{collections::BTreeMap, sync::Arc}; use crate::backend::Backend; use crate::backend::VersionInfo; +use crate::backend::options::BackendOptions; use crate::build_time::TARGET; use crate::cli::args::BackendArg; use crate::cmd::CmdLineRunner; @@ -11,7 +12,7 @@ use crate::http::HTTP; use crate::install_context::InstallContext; use crate::toolset::ToolSource::IdiomaticVersionFile; use crate::toolset::outdated_info::OutdatedInfo; -use crate::toolset::{ResolveOptions, ToolVersion, Toolset}; +use crate::toolset::{ResolveOptions, ToolVersion, ToolVersionOptions, Toolset}; use crate::ui::progress_report::SingleReport; use crate::{dirs, env, file, github, plugins}; use async_trait::async_trait; @@ -23,6 +24,46 @@ pub struct RustPlugin { ba: Arc, } +#[derive(Debug, Clone, Copy)] +struct RustOptions<'a> { + values: BackendOptions<'a>, +} + +impl<'a> RustOptions<'a> { + fn new(raw: &'a ToolVersionOptions) -> Self { + Self { + values: BackendOptions::new(raw), + } + } + + fn profile(&self) -> Option<&'a str> { + self.values.str("profile") + } + + fn comma_list(&self, name: &str) -> Option> { + self.values + .str(name) + .map(|c| c.split(',').map(|s| s.trim().to_string()).collect()) + } + + fn install_args( + &self, + rt: Option<&RustToolchain>, + ) -> (Option, Option>, Option>) { + let profile = rt + .and_then(|rt| rt.profile.clone()) + .or_else(|| self.profile().map(str::to_string)); + let components = rt + .and_then(|rt| rt.components.clone()) + .or_else(|| self.comma_list("components")); + let targets = rt + .and_then(|rt| rt.targets.clone()) + .or_else(|| self.comma_list("targets")); + + (profile, components, targets) + } +} + impl RustPlugin { pub fn new() -> Self { Self { @@ -271,26 +312,8 @@ fn get_args(tv: &ToolVersion) -> (Option, Option>, Option Result { @@ -411,3 +434,50 @@ fn cargo_bin() -> PathBuf { fn cargo_bindir() -> PathBuf { cargo_home().join("bin") } + +#[cfg(test)] +mod tests { + use super::*; + + fn opts_with(key: &str, value: &str) -> ToolVersionOptions { + let mut opts = ToolVersionOptions::default(); + opts.opts + .insert(key.to_string(), toml::Value::String(value.to_string())); + opts + } + + #[test] + fn rust_options_reads_install_args() { + let mut opts = opts_with("profile", "minimal"); + opts.opts.insert( + "components".to_string(), + toml::Value::String("clippy, rustfmt".to_string()), + ); + opts.opts.insert( + "targets".to_string(), + toml::Value::String("wasm32-wasip1".to_string()), + ); + + let (profile, components, targets) = RustOptions::new(&opts).install_args(None); + + assert_eq!(profile, Some("minimal".to_string())); + assert_eq!( + components, + Some(vec!["clippy".to_string(), "rustfmt".to_string()]) + ); + assert_eq!(targets, Some(vec!["wasm32-wasip1".to_string()])); + } + + #[test] + fn rust_idiomatic_options_override_tool_options() { + let opts = opts_with("profile", "minimal"); + let rt = RustToolchain { + profile: Some("default".to_string()), + ..Default::default() + }; + + let (profile, _, _) = RustOptions::new(&opts).install_args(Some(&rt)); + + assert_eq!(profile, Some("default".to_string())); + } +}