From ee96be08e719586b8ebf92b67bb3ca1e3b2eb282 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Fri, 1 May 2026 10:06:37 -0500 Subject: [PATCH 1/6] fix(install): don't warn for configured tools when version is passed via CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise install ruby@latest` was incorrectly showing "ruby installed but not activated — it is not in any config file" even when ruby is in a mise config file. `Install::run` writes CLI args into `env::TOOL_ARGS` before the tool request set is built. `ToolRequestSetBuilder::load_runtime_args` then merges those args back in with `ToolSource::Argument`, and `merge` gives the right-hand-side precedence — overwriting the `MiseToml` source for the tool. The inactive-tool check then sees `ToolSource::Argument` and fires for any `TOOL@VERSION` form, regardless of whether the tool is actually configured. Determine "configured" by walking `config.config_files` directly instead of reading the merged source map, and add regression coverage for both `dummy@1.0.0` and `dummy@latest` against a configured tool. Fixes #9501 Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/cli/test_install_inactive_hint | 7 +++++++ src/cli/install.rs | 22 ++++++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/e2e/cli/test_install_inactive_hint b/e2e/cli/test_install_inactive_hint index 606d409fdc..1bfa43f00f 100644 --- a/e2e/cli/test_install_inactive_hint +++ b/e2e/cli/test_install_inactive_hint @@ -13,5 +13,12 @@ assert_contains "mise install tiny@3.1.0 2>&1" "mise use tiny" mise use dummy@1.0.0 assert_not_contains "mise install dummy 2>&1" "not activated" +# Test: Installing TOOL@VERSION for a tool that IS in config should NOT show +# the hint (regression test for #9501 — CLI args were overriding the config +# source in the merged tool request set, falsely flagging configured tools as +# inactive). +assert_not_contains "mise install dummy@1.0.0 2>&1" "not activated" +assert_not_contains "mise install dummy@latest 2>&1" "not activated" + # Clean up mise use --rm dummy diff --git a/src/cli/install.rs b/src/cli/install.rs index 5949679dfa..502876b44d 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -147,14 +147,24 @@ impl Install { .iter() .map(|ta| ta.ba.short.clone()) .collect(); - // Collect inactive tool names before trs borrow is consumed + // Collect set of tools that appear in any config file. We can't use + // trs.sources here because load_runtime_args overrides the config-derived + // source with ToolSource::Argument whenever the user passes TOOL@VERSION. + let configured_tools: HashSet = config + .config_files + .values() + .filter_map(|cf| cf.to_tool_request_set().ok()) + .flat_map(|cf_trs| { + cf_trs + .tools + .keys() + .map(|ba| ba.short.clone()) + .collect::>() + }) + .collect(); let inactive_tools: Vec = expanded_runtimes .iter() - .filter(|ta| { - trs.sources - .get(ta.ba.as_ref()) - .is_none_or(|s| s.is_argument()) - }) + .filter(|ta| !configured_tools.contains(&ta.ba.short)) .map(|ta| ta.ba.short.clone()) .collect(); let mut ts: Toolset = trs.filter_by_tool(tools).into(); From 172ea20d33687fc1351d8a12bb9d9d0afafeccf1 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Fri, 1 May 2026 10:14:50 -0500 Subject: [PATCH 2/6] refactor(install): drop intermediate Vec and reuse tools set Address gemini review feedback on #9522: - use into_keys() to avoid the intermediate Vec allocation in flat_map - compute inactive_tools from the existing tools HashSet, which dedupes duplicate args (e.g. `mise install node@20 node@22`) for free Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/install.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/cli/install.rs b/src/cli/install.rs index 502876b44d..8e238b1b42 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -154,18 +154,12 @@ impl Install { .config_files .values() .filter_map(|cf| cf.to_tool_request_set().ok()) - .flat_map(|cf_trs| { - cf_trs - .tools - .keys() - .map(|ba| ba.short.clone()) - .collect::>() - }) + .flat_map(|cf_trs| cf_trs.tools.into_keys().map(|ba| ba.short.clone())) .collect(); - let inactive_tools: Vec = expanded_runtimes + let inactive_tools: Vec = tools .iter() - .filter(|ta| !configured_tools.contains(&ta.ba.short)) - .map(|ta| ta.ba.short.clone()) + .filter(|t| !configured_tools.contains(*t)) + .cloned() .collect(); let mut ts: Toolset = trs.filter_by_tool(tools).into(); let tool_versions = self.get_requested_tool_versions(&ts, &expanded_runtimes)?; From 66dbf8767282305bd1c35ed8359b88deee7bb41e Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Fri, 1 May 2026 10:17:37 -0500 Subject: [PATCH 3/6] fix(install): also treat MISE__VERSION env vars as activating Cursor Bugbot caught that the config-files-only check on #9522 regressed the env-var case: tools activated only via MISE__VERSION (e.g. MISE_NODE_VERSION=20) would now incorrectly emit the "not activated" warning, because they don't appear in config.config_files. The previous trs.sources-based check happened to handle them via ToolSource::Environment. We can't go back to trs.sources because load_runtime_args still overrides Environment with Argument the same way it overrides MiseToml. Mirror the MISE__VERSION parsing from ToolRequestSetBuilder::load_runtime_env and merge those names into configured_tools. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/cli/test_install_inactive_hint | 8 ++++++++ src/cli/install.rs | 23 ++++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/e2e/cli/test_install_inactive_hint b/e2e/cli/test_install_inactive_hint index 1bfa43f00f..e88cf0a032 100644 --- a/e2e/cli/test_install_inactive_hint +++ b/e2e/cli/test_install_inactive_hint @@ -22,3 +22,11 @@ assert_not_contains "mise install dummy@latest 2>&1" "not activated" # Clean up mise use --rm dummy + +# Test: Installing a tool that's only configured via MISE__VERSION env +# var should NOT show the hint (regression test for #9522 review feedback — +# the config-files-only check was missing env-var-configured tools). +MISE_DUMMY_VERSION=1.0.0 mise install dummy 2>&1 | tee /tmp/mise-install-env.log +assert_not_contains "cat /tmp/mise-install-env.log" "not activated" +MISE_DUMMY_VERSION=1.0.0 mise install dummy@1.0.0 2>&1 | tee /tmp/mise-install-env.log +assert_not_contains "cat /tmp/mise-install-env.log" "not activated" diff --git a/src/cli/install.rs b/src/cli/install.rs index 8e238b1b42..bb4783a26a 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -147,14 +147,31 @@ impl Install { .iter() .map(|ta| ta.ba.short.clone()) .collect(); - // Collect set of tools that appear in any config file. We can't use - // trs.sources here because load_runtime_args overrides the config-derived - // source with ToolSource::Argument whenever the user passes TOOL@VERSION. + // Collect set of tools that appear in any config file or in a + // MISE__VERSION env var. We can't use trs.sources here because + // load_runtime_args overrides the underlying source with + // ToolSource::Argument whenever the user passes TOOL@VERSION, so config- + // and env-sourced tools become indistinguishable from CLI-only ones. + let env_configured = env::vars_safe().filter_map(|(k, _)| { + if !k.starts_with("MISE_") || !k.ends_with("_VERSION") || k == "MISE_VERSION" { + return None; + } + let plugin_name = k + .trim_start_matches("MISE_") + .trim_end_matches("_VERSION") + .to_lowercase(); + // mirror load_runtime_env: ignore vars set during hooks + if plugin_name == "install" || plugin_name == "tool" { + return None; + } + Some(plugin_name) + }); let configured_tools: HashSet = config .config_files .values() .filter_map(|cf| cf.to_tool_request_set().ok()) .flat_map(|cf_trs| cf_trs.tools.into_keys().map(|ba| ba.short.clone())) + .chain(env_configured) .collect(); let inactive_tools: Vec = tools .iter() From e956054a3962aec9338f3683957ed132697a2e8f Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Fri, 1 May 2026 10:26:28 -0500 Subject: [PATCH 4/6] refactor(toolset): extract tool_env_vars helper Hoist the MISE__VERSION parsing out of install.rs (where it had been duplicated from ToolRequestSetBuilder::load_runtime_env) into a shared tool_env_vars() helper in tool_request_set.rs. Both call sites now share one definition of "what counts as a tool env var". No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/install.rs | 20 +++------------ src/toolset/mod.rs | 2 +- src/toolset/tool_request_set.rs | 45 ++++++++++++++++++++------------- 3 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/cli/install.rs b/src/cli/install.rs index bb4783a26a..b648b30140 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -7,7 +7,9 @@ use crate::config::Config; use crate::config::Settings; use crate::duration::parse_into_timestamp; use crate::hooks::Hooks; -use crate::toolset::{InstallOptions, ResolveOptions, ToolRequest, ToolSource, Toolset}; +use crate::toolset::{ + InstallOptions, ResolveOptions, ToolRequest, ToolSource, Toolset, tool_env_vars, +}; use crate::{config, env, exit, hooks}; use clap::ValueHint; use eyre::Result; @@ -152,26 +154,12 @@ impl Install { // load_runtime_args overrides the underlying source with // ToolSource::Argument whenever the user passes TOOL@VERSION, so config- // and env-sourced tools become indistinguishable from CLI-only ones. - let env_configured = env::vars_safe().filter_map(|(k, _)| { - if !k.starts_with("MISE_") || !k.ends_with("_VERSION") || k == "MISE_VERSION" { - return None; - } - let plugin_name = k - .trim_start_matches("MISE_") - .trim_end_matches("_VERSION") - .to_lowercase(); - // mirror load_runtime_env: ignore vars set during hooks - if plugin_name == "install" || plugin_name == "tool" { - return None; - } - Some(plugin_name) - }); let configured_tools: HashSet = config .config_files .values() .filter_map(|cf| cf.to_tool_request_set().ok()) .flat_map(|cf_trs| cf_trs.tools.into_keys().map(|ba| ba.short.clone())) - .chain(env_configured) + .chain(tool_env_vars().map(|(name, _, _)| name)) .collect(); let inactive_tools: Vec = tools .iter() diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index fe8d7e5c63..fabca83eaa 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -30,7 +30,7 @@ use tokio::sync::OnceCell; pub use install_options::InstallOptions; pub use tool_request::ToolRequest; -pub use tool_request_set::{ToolRequestSet, ToolRequestSetBuilder}; +pub use tool_request_set::{ToolRequestSet, ToolRequestSetBuilder, tool_env_vars}; pub use tool_source::ToolSource; pub use tool_version::{ResolveOptions, ToolVersion}; pub use tool_version_list::ToolVersionList; diff --git a/src/toolset/tool_request_set.rs b/src/toolset/tool_request_set.rs index 6000125853..a9f5ab66b8 100644 --- a/src/toolset/tool_request_set.rs +++ b/src/toolset/tool_request_set.rs @@ -204,25 +204,15 @@ impl ToolRequestSetBuilder { } fn load_runtime_env(&self, mut trs: ToolRequestSet) -> eyre::Result { - for (k, v) in env::vars_safe() { - if k.starts_with("MISE_") && k.ends_with("_VERSION") && k != "MISE_VERSION" { - let plugin_name = k - .trim_start_matches("MISE_") - .trim_end_matches("_VERSION") - .to_lowercase(); - if plugin_name == "install" || plugin_name == "tool" { - // ignore MISE_INSTALL_VERSION and MISE_TOOL_VERSION (set during hooks) - continue; - } - let ba: Arc = Arc::new(plugin_name.as_str().into()); - let source = ToolSource::Environment(k, v.clone()); - let mut env_ts = ToolRequestSet::new(); - for v in v.split_whitespace() { - let tvr = ToolRequest::new(ba.clone(), v, source.clone())?; - env_ts.add_version(tvr, &source); - } - trs = merge(trs, env_ts); + for (plugin_name, k, v) in tool_env_vars() { + let ba: Arc = Arc::new(plugin_name.as_str().into()); + let source = ToolSource::Environment(k, v.clone()); + let mut env_ts = ToolRequestSet::new(); + for v in v.split_whitespace() { + let tvr = ToolRequest::new(ba.clone(), v, source.clone())?; + env_ts.add_version(tvr, &source); } + trs = merge(trs, env_ts); } Ok(trs) } @@ -285,6 +275,25 @@ fn merge(mut a: ToolRequestSet, mut b: ToolRequestSet) -> ToolRequestSet { b } +/// Yields `(plugin_name, key, value)` for each `MISE__VERSION` env var +/// that maps to a tool. Skips `MISE_VERSION` and the `MISE_INSTALL_VERSION` / +/// `MISE_TOOL_VERSION` vars set during hooks. +pub fn tool_env_vars() -> impl Iterator { + env::vars_safe().filter_map(|(k, v)| { + if !k.starts_with("MISE_") || !k.ends_with("_VERSION") || k == "MISE_VERSION" { + return None; + } + let plugin_name = k + .trim_start_matches("MISE_") + .trim_end_matches("_VERSION") + .to_lowercase(); + if plugin_name == "install" || plugin_name == "tool" { + return None; + } + Some((plugin_name, k, v)) + }) +} + #[cfg(test)] mod tests { use super::*; From 4d54b4df6bac82232f7518b844140c57aa560eb1 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Fri, 1 May 2026 10:29:38 -0500 Subject: [PATCH 5/6] fix(install): unalias env-var-derived tool names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot caught that tool_env_vars yielded the raw plugin name extracted from the env var key, so MISE_NODEJS_VERSION produced "nodejs" but the tools set (built from ToolArg.ba.short, which is already unaliased) contained "node" — and the inactive-tool check falsely flagged node as not activated. Run the extracted name through unalias_backend so callers get the same canonical short the rest of the toolset already uses. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/cli/test_install_inactive_hint | 6 ++++++ src/toolset/tool_request_set.rs | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/e2e/cli/test_install_inactive_hint b/e2e/cli/test_install_inactive_hint index e88cf0a032..f5347760a9 100644 --- a/e2e/cli/test_install_inactive_hint +++ b/e2e/cli/test_install_inactive_hint @@ -30,3 +30,9 @@ MISE_DUMMY_VERSION=1.0.0 mise install dummy 2>&1 | tee /tmp/mise-install-env.log assert_not_contains "cat /tmp/mise-install-env.log" "not activated" MISE_DUMMY_VERSION=1.0.0 mise install dummy@1.0.0 2>&1 | tee /tmp/mise-install-env.log assert_not_contains "cat /tmp/mise-install-env.log" "not activated" + +# Test: env-var-configured tools are matched after backend aliasing — using +# `MISE_NODEJS_VERSION` (unaliases to `node`) and then `mise install node@…` +# should not warn. +MISE_NODEJS_VERSION=22.0.0 mise install node@22.0.0 --dry-run 2>&1 | tee /tmp/mise-install-env.log +assert_not_contains "cat /tmp/mise-install-env.log" "not activated" diff --git a/src/toolset/tool_request_set.rs b/src/toolset/tool_request_set.rs index a9f5ab66b8..a0ee14c8f4 100644 --- a/src/toolset/tool_request_set.rs +++ b/src/toolset/tool_request_set.rs @@ -204,8 +204,8 @@ impl ToolRequestSetBuilder { } fn load_runtime_env(&self, mut trs: ToolRequestSet) -> eyre::Result { - for (plugin_name, k, v) in tool_env_vars() { - let ba: Arc = Arc::new(plugin_name.as_str().into()); + for (short, k, v) in tool_env_vars() { + let ba: Arc = Arc::new(short.as_str().into()); let source = ToolSource::Environment(k, v.clone()); let mut env_ts = ToolRequestSet::new(); for v in v.split_whitespace() { @@ -275,22 +275,24 @@ fn merge(mut a: ToolRequestSet, mut b: ToolRequestSet) -> ToolRequestSet { b } -/// Yields `(plugin_name, key, value)` for each `MISE__VERSION` env var -/// that maps to a tool. Skips `MISE_VERSION` and the `MISE_INSTALL_VERSION` / -/// `MISE_TOOL_VERSION` vars set during hooks. +/// Yields `(short, key, value)` for each `MISE__VERSION` env var that +/// maps to a tool. `short` is the unaliased backend short name (so +/// `MISE_NODEJS_VERSION` yields `"node"`). Skips `MISE_VERSION` and the +/// `MISE_INSTALL_VERSION` / `MISE_TOOL_VERSION` vars set during hooks. pub fn tool_env_vars() -> impl Iterator { env::vars_safe().filter_map(|(k, v)| { if !k.starts_with("MISE_") || !k.ends_with("_VERSION") || k == "MISE_VERSION" { return None; } - let plugin_name = k + let raw = k .trim_start_matches("MISE_") .trim_end_matches("_VERSION") .to_lowercase(); - if plugin_name == "install" || plugin_name == "tool" { + if raw == "install" || raw == "tool" { return None; } - Some((plugin_name, k, v)) + let short = crate::backend::unalias_backend(&raw).to_string(); + Some((short, k, v)) }) } From 516d513c9952ba1d81909110c5285d0c3feff17d Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Fri, 1 May 2026 10:37:58 -0500 Subject: [PATCH 6/6] test(install): cover env-var unaliasing in unit tests, drop dead e2e check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot pointed out the --dry-run e2e case for MISE_NODEJS_VERSION never reached the inactive-tool warning (Install::install_runtimes returns early in dry-run mode), so it asserted nothing about the unaliasing behavior it claimed to test. Replace it with a unit test on tool_env_vars() that directly verifies MISE_NODEJS_VERSION → "node" and MISE_GOLANG_VERSION → "go", plus a companion test that the helper skips MISE_VERSION / MISE_INSTALL_VERSION / MISE_TOOL_VERSION. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/cli/test_install_inactive_hint | 11 +++---- src/toolset/tool_request_set.rs | 52 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/e2e/cli/test_install_inactive_hint b/e2e/cli/test_install_inactive_hint index f5347760a9..b8b49e10f3 100644 --- a/e2e/cli/test_install_inactive_hint +++ b/e2e/cli/test_install_inactive_hint @@ -30,9 +30,8 @@ MISE_DUMMY_VERSION=1.0.0 mise install dummy 2>&1 | tee /tmp/mise-install-env.log assert_not_contains "cat /tmp/mise-install-env.log" "not activated" MISE_DUMMY_VERSION=1.0.0 mise install dummy@1.0.0 2>&1 | tee /tmp/mise-install-env.log assert_not_contains "cat /tmp/mise-install-env.log" "not activated" - -# Test: env-var-configured tools are matched after backend aliasing — using -# `MISE_NODEJS_VERSION` (unaliases to `node`) and then `mise install node@…` -# should not warn. -MISE_NODEJS_VERSION=22.0.0 mise install node@22.0.0 --dry-run 2>&1 | tee /tmp/mise-install-env.log -assert_not_contains "cat /tmp/mise-install-env.log" "not activated" +# (Backend-alias coverage for env-var-configured tools — e.g. that +# MISE_NODEJS_VERSION matches `node` — lives in the tool_env_vars unit test +# in src/toolset/tool_request_set.rs; we don't repeat it here because +# installing node in e2e is expensive and `--dry-run` returns before the +# inactive-tool warning would fire.) diff --git a/src/toolset/tool_request_set.rs b/src/toolset/tool_request_set.rs index a0ee14c8f4..619203f151 100644 --- a/src/toolset/tool_request_set.rs +++ b/src/toolset/tool_request_set.rs @@ -322,6 +322,58 @@ mod tests { } } + #[test] + fn test_tool_env_vars_unaliases_backend() { + // MISE_NODEJS_VERSION should yield "node" (the unaliased backend + // short name), not "nodejs" — otherwise the install command's + // configured-tool check fails to match unaliased ToolArg shorts. + unsafe { + std::env::set_var("MISE_NODEJS_VERSION", "22.0.0"); + std::env::set_var("MISE_GOLANG_VERSION", "1.22.0"); + } + + let entries: Vec<(String, String, String)> = tool_env_vars() + .filter(|(_, k, _)| k == "MISE_NODEJS_VERSION" || k == "MISE_GOLANG_VERSION") + .collect(); + + let nodejs = entries + .iter() + .find(|(_, k, _)| k == "MISE_NODEJS_VERSION") + .expect("MISE_NODEJS_VERSION should yield an entry"); + assert_eq!(nodejs.0, "node"); + + let golang = entries + .iter() + .find(|(_, k, _)| k == "MISE_GOLANG_VERSION") + .expect("MISE_GOLANG_VERSION should yield an entry"); + assert_eq!(golang.0, "go"); + + unsafe { + std::env::remove_var("MISE_NODEJS_VERSION"); + std::env::remove_var("MISE_GOLANG_VERSION"); + } + } + + #[test] + fn test_tool_env_vars_skips_non_tool_vars() { + unsafe { + std::env::set_var("MISE_VERSION", "2026.4.28"); + std::env::set_var("MISE_INSTALL_VERSION", "1.0.0"); + std::env::set_var("MISE_TOOL_VERSION", "1.0.0"); + } + + let keys: HashSet = tool_env_vars().map(|(_, k, _)| k).collect(); + assert!(!keys.contains("MISE_VERSION")); + assert!(!keys.contains("MISE_INSTALL_VERSION")); + assert!(!keys.contains("MISE_TOOL_VERSION")); + + unsafe { + std::env::remove_var("MISE_VERSION"); + std::env::remove_var("MISE_INSTALL_VERSION"); + std::env::remove_var("MISE_TOOL_VERSION"); + } + } + #[test] fn test_load_runtime_env_ignores_non_mise_vars() { // Non-MISE variables should be ignored, even with special characters