use crate::request_exit;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{
collections::{BTreeSet, HashSet},
sync::atomic::Ordering,
};
use crate::backend::Backend;
use crate::cli::args::BackendArg;
use crate::cli::exec::Exec;
use crate::config::{CommandWrapper, Config, Settings, load_command_wrappers};
use crate::file::display_path;
use crate::lock_file::LockFile;
use crate::toolset::{ResolveOptions, ToolVersion, Toolset, ToolsetBuilder};
use crate::{backend, dirs, env, fake_asdf, file};
use color_eyre::eyre::{Result, bail, eyre};
use eyre::WrapErr;
use indoc::formatdoc;
use itertools::Itertools;
use path_absolutize::Absolutize;
use tokio::task::JoinSet;
pub(crate) async fn handle_shim() -> Result<()> {
let bin_name = *env::MISE_BIN_NAME;
if env::is_mise_binary(bin_name) || cfg!(test) {
return Ok(());
}
#[cfg(windows)]
{
let shim_path = invoked_shim_path();
if env::var_path(env::MISE_SHIM_PATH_ENV)
.as_ref()
.is_some_and(|previous| {
file::paths_eq(
&file::canonicalize_or_self(previous),
&file::canonicalize_or_self(&shim_path),
)
})
{
bail!(
"recursive shim invocation detected for {bin_name}: {}",
display_path(&shim_path)
);
}
*env::MISE_SHIM_PATH.write().unwrap() = Some(shim_path.clone());
env::set_var(env::MISE_SHIM_PATH_ENV, &shim_path);
}
let mut config = Config::get().await?;
let mut args = env::ARGS.read().unwrap().clone();
env::PREFER_OFFLINE.store(true, Ordering::Relaxed);
trace!("shim[{bin_name}] args: {}", args.join(" "));
let (bin, ts, wrapper) = which_shim(&mut config, &env::MISE_BIN_NAME, &args).await?;
args[0] = bin.to_string_lossy().to_string();
if let Some(wrapper) = &wrapper {
args.splice(1..1, wrapper.args().iter().cloned());
}
env::set_var("__MISE_SHIM", "1");
let exec = Exec {
tool: vec![],
c: None,
command: Some(args),
jobs: None,
raw: false,
no_deps: true, fresh_env: false,
deny_all: false,
deny_read: false,
deny_write: false,
deny_net: false,
deny_env: false,
allow_read: vec![],
allow_write: vec![],
allow_net: vec![],
allow_env: vec![],
};
time!("shim exec");
if let Some(wrapper) = wrapper {
exec.run_with_command_wrapper(
config,
ts,
wrapper
.env()
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
)
.await?;
} else {
exec.run_with_toolset(config, ts).await?;
}
Err(request_exit(0))
}
#[cfg(windows)]
fn invoked_shim_path() -> PathBuf {
let argv0 = PathBuf::from(&*env::ARGV0);
if argv0.is_absolute() {
return argv0;
}
if argv0.components().count() > 1 {
return argv0
.absolutize()
.map(|path| path.into_owned())
.unwrap_or(argv0);
}
which::which(&argv0)
.ok()
.or_else(|| std::env::current_exe().ok())
.unwrap_or(argv0)
}
async fn which_shim(
config: &mut Arc<Config>,
bin_name: &str,
args: &[String],
) -> Result<(PathBuf, Toolset, Option<CommandWrapper>)> {
let bin_stem = bin_name
.strip_suffix(std::env::consts::EXE_SUFFIX)
.unwrap_or(bin_name);
let completion_offline =
bin_stem == "usage" && args.get(1).is_some_and(|arg| arg == "complete-word");
let resolve_options = if completion_offline {
ResolveOptions {
offline: true,
..Default::default()
}
} else {
ResolveOptions::default()
};
let mut ts = ToolsetBuilder::new()
.with_resolve_options(resolve_options)
.build(config)
.await?;
let wrappers = load_command_wrappers(&config.config_files)?;
validate_wrapper_names(wrappers.keys())?;
let wrapper = if cfg!(macos) {
wrappers
.iter()
.find(|(name, _)| command_names_eq(name, bin_stem))
.map(|(_, wrapper)| wrapper)
} else {
wrappers.get(bin_stem)
};
if let Some(wrapper) = wrapper {
if command_names_eq(wrapper.command(), bin_stem) {
bail!("command wrapper for {bin_stem} cannot delegate to itself");
}
trace!("shim[{bin_name}] WRAPPER command: {}", wrapper.command());
return Ok((PathBuf::from(wrapper.command()), ts, Some(wrapper.clone())));
}
if !completion_offline
&& Settings::get().not_found_auto_install
&& ts
.should_install_missing_registry_bin_provider(config, bin_name)
.await?
{
for tv in ts
.install_missing_bin(config, bin_name)
.await?
.unwrap_or_default()
{
let p = tv.backend()?;
if let Some(bin) = p.which(config, &tv, bin_name).await? {
trace!(
"shim[{bin_name}] REGISTRY ToolVersion: {tv} bin: {bin}",
bin = display_path(&bin)
);
return Ok((bin, ts, None));
}
}
}
if let Some((p, tv)) = ts.which(config, bin_name).await
&& let Some(bin) = p.which(config, &tv, bin_name).await?
{
trace!(
"shim[{bin_name}] ToolVersion: {tv} bin: {bin}",
bin = display_path(&bin)
);
return Ok((bin, ts, None));
}
if !completion_offline && Settings::get().not_found_auto_install {
for tv in ts
.install_missing_bin(config, bin_name)
.await?
.unwrap_or_default()
{
let p = tv.backend()?;
if let Some(bin) = p.which(config, &tv, bin_name).await? {
trace!(
"shim[{bin_name}] NOT_FOUND ToolVersion: {tv} bin: {bin}",
bin = display_path(&bin)
);
return Ok((bin, ts, None));
}
}
}
if Settings::get().not_found_system_fallback {
let mise_bin = file::canonicalize_or_self(&env::MISE_BIN);
for path in &*env::PATH {
if file::is_mise_shims_dir(path) || file::is_command_wrapper_dir(path) {
continue;
}
let bin = path.join(bin_name);
if bin.is_file() && file::is_executable(&bin) {
if file::is_active_mise_shim(&bin) {
continue;
}
if file::canonicalize_cached(&bin).is_some_and(|bin| bin == mise_bin) {
continue;
}
trace!("shim[{bin_name}] SYSTEM {bin}", bin = display_path(&bin));
return Ok((bin, ts, None));
}
}
}
let tvs = ts.list_rtvs_with_bin(config, bin_name).await?;
match err_no_version_set(config, ts, bin_name, tvs).await {
Ok(_) => unreachable!("err_no_version_set always returns an error"),
Err(err) => Err(err),
}
}
#[cfg(not(test))]
pub(crate) async fn err_shim_not_found(bin_name: &str) -> color_eyre::Report {
let bin_stem = bin_name
.strip_suffix(std::env::consts::EXE_SUFFIX)
.unwrap_or(bin_name);
let build = async {
let config = Config::get().await?;
let ts = ToolsetBuilder::new().build(&config).await?;
let tvs = ts.list_rtvs_with_bin(&config, bin_stem).await?;
err_no_version_set(&config, ts, bin_stem, tvs)
.await
.map(|_| eyre!("cannot find binary path: {bin_stem}"))
};
match build.await {
Ok(report) | Err(report) => report,
}
}
pub(crate) async fn reshim(config: &Arc<Config>, ts: &Toolset, force: bool) -> Result<()> {
let _lock = LockFile::new(&dirs::SHIMS)
.with_callback(|l| {
trace!("reshim callback {}", l.display());
})
.lock();
let mise_bin = mise_bin_for_shims();
let mise_bin = mise_bin.absolutize()?;
#[cfg(windows)]
let shim_mode = effective_shim_mode(&mise_bin);
#[cfg(not(windows))]
let shim_mode = String::new();
let shim_mode_changed = cfg!(windows) && {
let mode_file = dirs::SHIMS.join(".mode");
mode_file
.exists()
.then(|| fs::read_to_string(&mode_file).unwrap_or_default())
.is_some_and(|prev| prev.trim() != shim_mode)
};
let shim_version = env!("CARGO_PKG_VERSION");
let shim_version_changed = cfg!(windows) && {
let version_file = dirs::SHIMS.join(".version");
let prev = fs::read_to_string(&version_file).ok();
shim_version_stale(prev.as_deref(), shim_version, &shim_mode)
};
if force || shim_mode_changed || shim_version_changed {
if cfg!(windows) {
remove_shims_individually(&dirs::SHIMS)?;
} else {
file::remove_all(*dirs::SHIMS)?;
}
}
file::create_dir_all(*dirs::SHIMS)?;
if cfg!(windows) {
let mode_file = dirs::SHIMS.join(".mode");
file::write(&mode_file, &shim_mode)?;
let version_file = dirs::SHIMS.join(".version");
file::write(&version_file, shim_version)?;
}
let (shims_to_add, shims_to_remove) = if force || shim_mode_changed || shim_version_changed {
let desired = get_desired_shims(config, &mise_bin, ts).await?;
(
desired.into_iter().collect::<BTreeSet<_>>(),
BTreeSet::new(),
)
} else {
let diffs = get_shim_diffs(config, &mise_bin, ts).await?;
(diffs.missing, diffs.extra)
};
for shim in shims_to_add {
let symlink_path = dirs::SHIMS.join(&shim);
if cfg!(windows) && symlink_path.exists() {
remove_shim_with_rename_fallback(&symlink_path)?;
}
add_shim(&mise_bin, &symlink_path, &shim)?;
}
for shim in shims_to_remove {
let symlink_path = dirs::SHIMS.join(shim);
if cfg!(windows) {
remove_shim_with_rename_fallback(&symlink_path)?;
} else {
file::remove_all(&symlink_path)?;
}
}
let mut jset = JoinSet::new();
for plugin in backend::list() {
jset.spawn(async move {
if let Ok(files) = dirs::PLUGINS.join(plugin.id()).join("shims").read_dir() {
for bin in files {
let bin = bin?;
let bin_name = bin.file_name().into_string().unwrap();
let symlink_path = dirs::SHIMS.join(bin_name);
make_shim(&bin.path(), &symlink_path).await?;
}
}
Ok(())
});
}
jset.join_all()
.await
.into_iter()
.collect::<Result<Vec<_>>>()?;
sync_command_wrapper_shims(
config,
&mise_bin,
force || shim_mode_changed || shim_version_changed,
)?;
Ok(())
}
fn sync_command_wrapper_shims(config: &Config, mise_bin: &Path, force: bool) -> Result<()> {
let wrappers = load_command_wrappers(&config.config_files)?;
validate_wrapper_names(wrappers.keys())?;
if wrappers.is_empty() {
if cfg!(windows) {
remove_shims_individually(&dirs::COMMAND_WRAPPERS)?;
} else {
file::remove_all(&*dirs::COMMAND_WRAPPERS)?;
}
return Ok(());
}
if force {
if cfg!(windows) {
remove_shims_individually(&dirs::COMMAND_WRAPPERS)?;
} else {
file::remove_all(&*dirs::COMMAND_WRAPPERS)?;
}
}
file::create_dir_all(&*dirs::COMMAND_WRAPPERS)?;
let mut desired = HashSet::new();
for name in wrappers.keys() {
desired.extend(platform_shim_names(mise_bin, name));
}
let actual = list_shims_in(&dirs::COMMAND_WRAPPERS)?;
for shim in desired.difference(&actual) {
let path = dirs::COMMAND_WRAPPERS.join(shim);
if cfg!(windows) && path.exists() {
remove_shim_with_rename_fallback(&path)?;
}
add_shim(mise_bin, &path, shim)?;
}
for shim in actual.difference(&desired) {
let path = dirs::COMMAND_WRAPPERS.join(shim);
if cfg!(windows) {
remove_shim_with_rename_fallback(&path)?;
} else {
file::remove_all(&path)?;
}
}
Ok(())
}
fn command_names_eq(a: &str, b: &str) -> bool {
if cfg!(macos) {
a.to_lowercase() == b.to_lowercase()
} else {
a == b
}
}
fn validate_wrapper_name(name: &str) -> Result<()> {
if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') {
bail!("invalid command wrapper name: {name:?}");
}
if cfg!(windows) && name.contains('.') {
bail!("command wrapper names cannot contain dots on Windows: {name:?}");
}
Ok(())
}
fn validate_wrapper_names<'a>(names: impl IntoIterator<Item = &'a String>) -> Result<()> {
let mut normalized = HashSet::new();
for name in names {
validate_wrapper_name(name)?;
if cfg!(macos) && !normalized.insert(name.to_lowercase()) {
bail!("command wrapper names collide on macOS after case normalization: {name:?}");
}
}
Ok(())
}
pub(crate) fn mise_bin_for_shims() -> PathBuf {
env::var_path("SNAP")
.as_deref()
.and_then(|snap| snap_mise_bin(&env::MISE_BIN, snap))
.unwrap_or_else(|| file::which_no_shims("mise").unwrap_or(env::MISE_BIN.clone()))
}
fn snap_mise_bin(mise_bin: &Path, snap: &Path) -> Option<PathBuf> {
let relative = mise_bin
.strip_prefix(snap)
.map(Path::to_path_buf)
.or_else(|_| {
let mise_bin = file::canonicalize_or_self(mise_bin);
let snap = file::canonicalize_or_self(snap);
mise_bin.strip_prefix(snap).map(Path::to_path_buf)
})
.ok()?;
let snap_mount = snap.parent()?;
Some(snap_mount.join("current").join(relative))
}
fn remove_shims_individually(shims_dir: &Path) -> Result<()> {
let entries = match shims_dir.read_dir() {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(e).wrap_err_with(|| {
format!(
"failed to read shims directory: {}",
display_path(shims_dir)
)
});
}
};
for entry in entries {
let entry = entry?;
let name = entry.file_name();
if is_hidden_shim_name(&name) {
continue;
}
let path = entry.path();
remove_shim_with_rename_fallback(&path)?;
}
Ok(())
}
fn remove_shim_with_rename_fallback(path: &Path) -> Result<()> {
let old_path = old_shim_path(path);
if old_path.exists() {
let _ = fs::remove_file(&old_path); }
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if cfg!(windows) && matches!(e.raw_os_error(), Some(5) | Some(32)) => {
trace!(
"cannot delete locked shim {}, renaming to .old",
display_path(path)
);
fs::rename(path, &old_path).wrap_err_with(|| {
format!(
"failed to rename locked shim {} to {}",
display_path(path),
display_path(&old_path)
)
})?;
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).wrap_err_with(|| format!("failed to remove shim: {}", display_path(path))),
}
}
fn old_shim_path(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".old");
path.with_file_name(name)
}
pub(crate) fn find_mise_shim_bin(mise_bin: &Path) -> Option<PathBuf> {
if let Some(parent) = mise_bin.parent() {
let candidate = parent.join("mise-shim.exe");
if candidate.is_file() {
return Some(candidate);
}
}
file::which("mise-shim.exe").filter(|p| p.is_file())
}
#[cfg(windows)]
fn effective_shim_mode(mise_bin: &Path) -> String {
let mode = Settings::get().windows_shim_mode.clone();
if mode == "exe" && find_mise_shim_bin(mise_bin).is_none() {
warn_once!(
"mise-shim.exe not found next to {} or on PATH, falling back to \"file\" shim mode",
display_path(mise_bin)
);
return "file".to_string();
}
mode
}
#[cfg(windows)]
fn bash_shim_script(tool: &str) -> String {
formatdoc! {r#"
#!/bin/bash
shim_dir=$(cd -- "$(dirname -- "$0")" && pwd -P)
shim_path="$shim_dir/${{0##*/}}"
if [ "${{__MISE_SHIM_PATH:-}}" = "$shim_path" ]; then
echo "mise: recursive shim invocation detected for {tool}: $shim_path" >&2
exit 1
fi
if [ -n "${{WSL_DISTRO_NAME:-}}" ] || [ -n "${{WSL_INTEROP:-}}" ] || [ -e /proc/sys/fs/binfmt_misc/WSLInterop ]; then
new_path=
# disable globbing so a PATH entry containing * ? [ is not expanded
set -f
IFS=:
for p in $PATH; do
[ "$p" = "$shim_dir" ] && continue
new_path="${{new_path:+$new_path:}}$p"
done
unset IFS
set +f
export PATH="$new_path"
exec {tool} "$@"
fi
export __MISE_SHIM_PATH="$shim_path"
exec mise x -- {tool} "$@"
"#}
}
#[cfg(any(windows, test))]
pub(crate) fn windows_file_shim_body(shim: &str) -> String {
let raw = env::LAUNCHER_RAW_CMDLINE_ENV;
let path = env::LAUNCHER_PATH_ENV;
let sentinel = env::LAUNCHER_ARGS_SENTINEL;
let shim_env = env::MISE_SHIM_PATH_ENV;
let run = format!("mise x -- {shim} {sentinel} %*");
[
"@echo off",
"setlocal DisableDelayedExpansion",
"set \"shim_path=%~f0\"",
&format!("if /I \"%{shim_env}%\"==\"%shim_path%\" ("),
&format!(" echo mise: recursive shim invocation detected for {shim}: %shim_path% 1>&2"),
" exit /b 1",
")",
&format!("set \"{shim_env}=%shim_path%\""),
"setlocal EnableDelayedExpansion",
&format!("set \"{path}=!shim_path!\""),
&format!("set \"{raw}=!CMDCMDLINE!\""),
&format!("if \"!{raw}!\"==\"!{raw}:%{path}%=!\" goto mise_shim_fallback"),
&run,
"exit !ERRORLEVEL!",
":mise_shim_fallback",
&format!("set \"{raw}=\""),
&format!("set \"{path}=\""),
&run,
]
.join("\r\n")
+ "\r\n"
}
#[cfg(windows)]
fn add_shim(mise_bin: &Path, symlink_path: &Path, shim: &str) -> Result<()> {
match effective_shim_mode(mise_bin).as_ref() {
"exe" => {
let mise_shim_bin =
find_mise_shim_bin(mise_bin).ok_or_else(|| eyre!("mise-shim.exe not found"))?;
fs::copy(&mise_shim_bin, symlink_path).wrap_err_with(|| {
eyre!(
"Failed to copy {} to {}",
display_path(&mise_shim_bin),
display_path(symlink_path)
)
})?;
Ok(())
}
"file" => {
let shim = shim.trim_end_matches(".cmd");
file::write(symlink_path.with_extension(""), bash_shim_script(shim)).wrap_err_with(
|| {
eyre!(
"Failed to create symlink from {} to {}",
display_path(mise_bin),
display_path(symlink_path)
)
},
)?;
file::write(
symlink_path.with_extension("cmd"),
windows_file_shim_body(shim),
)
.wrap_err_with(|| {
eyre!(
"Failed to create symlink from {} to {}",
display_path(mise_bin),
display_path(symlink_path)
)
})
}
"hardlink" => fs::hard_link(mise_bin, symlink_path).wrap_err_with(|| {
eyre!(
"Failed to create hardlink from {} to {}",
display_path(mise_bin),
display_path(symlink_path)
)
}),
"symlink" => {
std::os::windows::fs::symlink_file(mise_bin, symlink_path).wrap_err_with(|| {
eyre!(
"Failed to create symlink from {} to {}",
display_path(mise_bin),
display_path(symlink_path)
)
})
}
_ => panic!("Unknown shim mode"),
}
}
#[cfg(unix)]
fn add_shim(mise_bin: &Path, symlink_path: &Path, _shim: &str) -> Result<()> {
file::make_symlink(mise_bin, symlink_path).wrap_err_with(|| {
eyre!(
"Failed to create symlink from {} to {}",
display_path(mise_bin),
display_path(symlink_path)
)
})?;
Ok(())
}
pub(crate) struct ShimDiffs {
pub missing: BTreeSet<String>,
pub extra: BTreeSet<String>,
pub desired: HashSet<String>,
}
pub(crate) async fn get_shim_diffs(
config: &Arc<Config>,
mise_bin: impl AsRef<Path>,
toolset: &Toolset,
) -> Result<ShimDiffs> {
let mise_bin = mise_bin.as_ref();
let (actual_shims, desired_shims) = tokio::join!(
get_actual_shims(mise_bin),
get_desired_shims(config, mise_bin, toolset)
);
let (actual_shims, desired_shims) = (actual_shims?, desired_shims?);
let missing: BTreeSet<_> = desired_shims.difference(&actual_shims).cloned().collect();
let extra: BTreeSet<_> = actual_shims.difference(&desired_shims).cloned().collect();
time!("get_shim_diffs sizes: ({},{})", missing.len(), extra.len());
Ok(ShimDiffs {
missing,
extra,
desired: desired_shims,
})
}
async fn get_actual_shims(mise_bin: impl AsRef<Path>) -> Result<HashSet<String>> {
let mise_bin = mise_bin.as_ref();
Ok(list_shims()?
.into_iter()
.filter(|bin| {
let path = dirs::SHIMS.join(bin);
!path.is_symlink() || path.read_link().is_ok_and(|p| p == mise_bin)
})
.collect::<HashSet<_>>())
}
fn list_executables_in_dir(dir: &Path) -> Result<HashSet<String>> {
Ok(dir
.read_dir()?
.map(|bin| {
let bin = bin?;
let name = bin.file_name();
if is_hidden_shim_name(&name) {
return Ok(None);
}
if file::is_executable(&bin.path())
&& (bin.file_type()?.is_file() || bin.file_type()?.is_symlink())
{
Ok(name.into_string().ok())
} else {
Ok(None)
}
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect())
}
fn list_shims() -> Result<HashSet<String>> {
list_shims_in(&dirs::SHIMS)
}
fn list_shims_in(dir: &Path) -> Result<HashSet<String>> {
Ok(dir
.read_dir()?
.map(|bin| {
let bin = bin?;
let name = bin.file_name();
if is_hidden_shim_name(&name) {
return Ok(None);
}
if (file::is_executable(&bin.path()) || bin.path().extension().is_none())
&& (bin.file_type()?.is_file() || bin.file_type()?.is_symlink())
{
Ok(name.into_string().ok())
} else {
Ok(None)
}
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect())
}
fn is_hidden_shim_name(name: &std::ffi::OsStr) -> bool {
name.to_string_lossy().starts_with('.')
}
fn shim_version_stale(prev: Option<&str>, current: &str, shim_mode: &str) -> bool {
if !matches!(shim_mode, "exe" | "hardlink" | "file") {
return false;
}
prev.map(|p| p.trim() != current).unwrap_or(true)
}
async fn get_desired_shims(
config: &Arc<Config>,
mise_bin: &Path,
toolset: &Toolset,
) -> Result<HashSet<String>> {
let _mise_bin = mise_bin; let mut shims = HashSet::new();
for (t, tv) in toolset.list_installed_versions(config).await? {
let bins = list_tool_bins(config, t.clone(), &tv)
.await
.unwrap_or_else(|e| {
warn!("Error listing bin paths for {}: {:#}", tv, e);
Vec::new()
});
shims.extend(
bins.into_iter()
.flat_map(|b| platform_shim_names(_mise_bin, &b)),
);
}
Ok(shims)
}
fn platform_shim_names(_mise_bin: &Path, bin: &str) -> Vec<String> {
if cfg!(windows) {
#[cfg(windows)]
let shim_mode = effective_shim_mode(_mise_bin);
#[cfg(not(windows))]
let shim_mode = String::new();
let p = PathBuf::from(bin);
match shim_mode.as_ref() {
"hardlink" | "symlink" | "exe" => {
vec![p.with_extension("exe").to_string_lossy().to_string()]
}
"file" => vec![
p.with_extension("").to_string_lossy().to_string(),
p.with_extension("cmd").to_string_lossy().to_string(),
],
_ => panic!("Unknown shim mode"),
}
} else if cfg!(macos) {
vec![bin.to_lowercase()]
} else {
vec![bin.to_string()]
}
}
async fn list_tool_bins(
config: &Arc<Config>,
t: Arc<dyn Backend>,
tv: &ToolVersion,
) -> Result<Vec<String>> {
Ok(t.list_bin_paths(config, tv)
.await?
.into_iter()
.filter(|p| p.parent().is_some())
.filter(|path| path.exists())
.map(|dir| list_executables_in_dir(&dir))
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect())
}
async fn make_shim(target: &Path, shim: &Path) -> Result<()> {
file::remove_file_async_if_exists(shim).await?;
file::write_async(
shim,
formatdoc! {r#"
#!/bin/sh
export ASDF_DATA_DIR={data_dir}
export PATH="{fake_asdf_dir}:$PATH"
mise x -- {target} "$@"
"#,
data_dir = dirs::DATA.display(),
fake_asdf_dir = fake_asdf::setup()?.display(),
target = target.display()},
)
.await?;
file::make_executable_async(shim).await?;
trace!(
"shim created from {} to {}",
target.display(),
shim.display()
);
Ok(())
}
async fn err_no_version_set(
config: &Arc<Config>,
ts: Toolset,
bin_name: &str,
tvs: Vec<ToolVersion>,
) -> Result<PathBuf> {
if tvs.is_empty() {
bail!(
"{bin_name} is not a valid shim. This likely means you uninstalled a tool and the shim does not point to anything. Run `mise use <TOOL>` to reinstall the tool."
);
}
let missing_plugins = tvs.iter().map(|tv| tv.ba()).collect::<HashSet<_>>();
let mut missing_tools = ts
.list_missing_versions(config)
.await
.into_iter()
.filter(|t| missing_plugins.contains(t.ba()))
.collect_vec();
if missing_tools.is_empty() {
if let Some(msg) = unavailable_configured_tool_message(config, &ts, bin_name) {
return Err(eyre!(msg));
}
let mut msg = format!("No version is set for shim: {bin_name}\n");
msg.push_str("Set a global default version with one of the following:\n");
for tv in tvs {
msg.push_str(&format!("mise use -g {}@{}\n", tv.ba(), tv.version));
}
Err(eyre!(msg.trim().to_string()))
} else {
let mut msg = format!(
"Tool{} not installed for shim: {}\n",
if missing_tools.len() > 1 { "s" } else { "" },
bin_name
);
for t in missing_tools.drain(..) {
msg.push_str(&format!("Missing tool version: {t}\n"));
}
msg.push_str("Install all missing tools with: mise install\n");
Err(eyre!(msg.trim().to_string()))
}
}
pub(crate) fn unavailable_configured_tool_message(
config: &Arc<Config>,
ts: &Toolset,
bin_name: &str,
) -> Option<String> {
let versions = ts
.list_current_versions()
.into_iter()
.filter(|(backend, tv)| {
tv.ba().matches_bin_name(bin_name) && backend.is_version_installed(config, tv, true)
})
.map(|(_, tv)| tv)
.collect_vec();
if versions.is_empty() {
return None;
}
let mut msg = format!("No executable found for configured tool: {bin_name}\n");
msg.push_str(
"The installed version does not provide this executable with its current backend metadata.\n",
);
msg.push_str("Reinstall it with:\n");
for tv in versions {
msg.push_str(&format!(
"mise install --force {}@{}\n",
tv.ba(),
tv.version
));
}
Some(msg.trim().to_string())
}
pub(crate) fn inactive_installed_tool_message(
ts: &Toolset,
installed_shorts: &[String],
bin_name: &str,
) -> Option<String> {
if ts.versions.keys().any(|ba| ba.matches_bin_name(bin_name)) {
return None;
}
let shorts = installed_shorts
.iter()
.filter(|short| BackendArg::from(short.as_str()).matches_bin_name(bin_name))
.collect_vec();
if shorts.is_empty() {
return None;
}
let mut msg =
format!("{bin_name} is installed but not activated — it is not in any config file.\n");
msg.push_str("To activate it, run:\n");
for short in &shorts {
msg.push_str(&format!(" mise use {short}\n"));
}
msg.push_str("To run it without changing any config file, run:\n");
for short in &shorts {
msg.push_str(&format!(" mise exec {short} -- {bin_name}\n"));
}
Some(msg.trim().to_string())
}
pub(crate) fn os_unsupported_tool_message(bin_name: &str) -> Option<String> {
let shorts = crate::registry::REGISTRY
.values()
.unique_by(|rt| rt.short)
.filter(|rt| !rt.is_supported_os())
.filter(|rt| rt.short == bin_name || rt.bins.contains(&bin_name))
.map(|rt| (rt.short, rt.os.join(", ")))
.collect_vec();
if shorts.is_empty() {
return None;
}
let mut msg = String::new();
for (short, oses) in &shorts {
let provides = if *short == bin_name {
String::new()
} else {
format!(", which provides {bin_name},")
};
msg.push_str(&format!(
"{short}{provides} is not available on {}: mise's registry lists it for {oses} only.\n",
std::env::consts::OS,
));
}
Some(msg.trim().to_string())
}
#[cfg(not(test))]
pub(crate) async fn exec_resolution_hint(bin_name: &str) -> Option<String> {
let bin_stem = bin_name
.strip_suffix(std::env::consts::EXE_SUFFIX)
.unwrap_or(bin_name);
let config = Config::get().await.ok()?;
let ts = ToolsetBuilder::new().build(&config).await.ok()?;
let settings = Settings::get();
let enable_tools = settings.enable_tools();
let disable_tools = settings.disable_tools();
let installed_shorts = crate::toolset::install_state::list_tools()
.values()
.filter(|t| !t.versions.is_empty())
.map(|t| t.short.clone())
.filter(|short| crate::registry::tool_enabled(enable_tools.as_ref(), &disable_tools, short))
.collect_vec();
inactive_installed_tool_message(&ts, &installed_shorts, bin_stem)
.or_else(|| os_unsupported_tool_message(bin_stem))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::args::BackendArg;
use crate::toolset::{ToolRequest, ToolSource, ToolVersionList};
#[test]
fn locked_windows_shims_get_distinct_old_paths() {
assert_eq!(old_shim_path(Path::new("foo")), PathBuf::from("foo.old"));
assert_eq!(
old_shim_path(Path::new("foo.cmd")),
PathBuf::from("foo.cmd.old")
);
}
#[cfg(macos)]
#[test]
fn case_colliding_macos_wrapper_names_are_rejected() {
let names = ["Foo".to_string(), "foo".to_string()];
assert!(validate_wrapper_names(&names).is_err());
}
#[cfg(windows)]
#[test]
fn dotted_windows_wrapper_names_are_rejected() {
let names = ["foo.bar".to_string()];
assert!(validate_wrapper_names(&names).is_err());
}
#[test]
fn snap_mise_bin_uses_refresh_stable_current_path() {
assert_eq!(
snap_mise_bin(
Path::new("/snap/mise/189/bin/mise"),
Path::new("/snap/mise/189")
),
Some(PathBuf::from("/snap/mise/current/bin/mise"))
);
}
#[test]
fn snap_mise_bin_rejects_unrelated_executable() {
assert_eq!(
snap_mise_bin(
Path::new("/home/user/.local/bin/mise"),
Path::new("/snap/mise/189")
),
None
);
}
#[cfg(unix)]
#[test]
fn snap_mise_bin_handles_symlinked_snap_mount() {
let temp = tempfile::tempdir().unwrap();
let canonical_mount = temp.path().join("var/lib/snapd/snap");
let canonical_snap = canonical_mount.join("mise/189");
let mise_bin = canonical_snap.join("bin/mise");
fs::create_dir_all(mise_bin.parent().unwrap()).unwrap();
fs::write(&mise_bin, "").unwrap();
let snap_mount = temp.path().join("snap");
std::os::unix::fs::symlink(&canonical_mount, &snap_mount).unwrap();
let snap = snap_mount.join("mise/189");
assert_eq!(
snap_mise_bin(&mise_bin, &snap),
Some(snap_mount.join("mise/current/bin/mise"))
);
}
#[tokio::test]
async fn unavailable_tool_message_prefers_matching_configured_tool() {
let config = Config::get().await.unwrap();
let temp = tempfile::tempdir().unwrap();
let mut ts = Toolset::new(ToolSource::Argument);
for name in ["codex", "node"] {
let ba = Arc::new(BackendArg::from(name));
let request = ToolRequest::new(ba.clone(), "1.0.0", ToolSource::Argument).unwrap();
let mut tv = ToolVersion::new(request.clone(), "1.0.0".into());
let install_path = temp.path().join(name);
file::create_dir_all(&install_path).unwrap();
tv.install_path = Some(install_path);
let mut tvl = ToolVersionList::new(ba.clone(), ToolSource::Argument);
tvl.requests.push(request);
tvl.versions.push(tv);
ts.versions.insert(ba, tvl);
}
let msg = unavailable_configured_tool_message(&config, &ts, "codex").unwrap();
assert!(msg.contains("mise install --force codex@1.0.0"));
assert!(!msg.contains("node@1.0.0"));
}
#[tokio::test]
async fn inactive_tool_message_names_the_installed_tool() {
let _config = Config::get().await.unwrap();
let ts = Toolset::new(ToolSource::Argument);
let msg = inactive_installed_tool_message(&ts, &["gh".to_string()], "gh").unwrap();
assert!(msg.contains("installed but not activated"));
assert!(msg.contains("mise use gh"));
assert!(msg.contains("mise exec gh -- gh"));
}
#[tokio::test]
async fn inactive_tool_message_is_none_for_configured_tool() {
let _config = Config::get().await.unwrap();
let mut ts = Toolset::new(ToolSource::Argument);
let ba = Arc::new(BackendArg::from("gh"));
let request = ToolRequest::new(ba.clone(), "1.0.0", ToolSource::Argument).unwrap();
let tv = ToolVersion::new(request.clone(), "1.0.0".into());
let mut tvl = ToolVersionList::new(ba.clone(), ToolSource::Argument);
tvl.requests.push(request);
tvl.versions.push(tv);
ts.versions.insert(ba, tvl);
assert!(inactive_installed_tool_message(&ts, &["gh".to_string()], "gh").is_none());
}
#[tokio::test]
async fn inactive_tool_message_is_none_for_a_configured_tool_with_no_resolved_versions() {
let _config = Config::get().await.unwrap();
let mut ts = Toolset::new(ToolSource::Argument);
let ba = Arc::new(BackendArg::from("gh"));
ts.versions.insert(
ba.clone(),
ToolVersionList::new(ba, ToolSource::Argument), );
assert!(inactive_installed_tool_message(&ts, &["gh".to_string()], "gh").is_none());
}
#[tokio::test]
async fn inactive_tool_message_is_none_when_bin_does_not_name_the_tool() {
let _config = Config::get().await.unwrap();
let ts = Toolset::new(ToolSource::Argument);
assert!(inactive_installed_tool_message(&ts, &["node".to_string()], "npm").is_none());
}
#[test]
fn os_unsupported_tool_message_names_the_tool_and_this_platform() {
let rt = crate::registry::REGISTRY
.values()
.unique_by(|rt| rt.short)
.find(|rt| !rt.is_supported_os() && !rt.bins.is_empty())
.expect("the registry lists no tool this platform is excluded from");
let msg = os_unsupported_tool_message(rt.bins[0])
.expect("a bin only an excluded tool provides should be explained");
assert!(msg.contains(rt.short), "{msg}");
assert!(msg.contains(std::env::consts::OS), "{msg}");
assert!(msg.contains(rt.os[0]), "{msg}");
}
#[test]
fn os_unsupported_tool_message_is_silent_when_nothing_is_excluded() {
assert_eq!(os_unsupported_tool_message("node"), None);
assert_eq!(os_unsupported_tool_message("not-a-registry-bin-9f3a"), None);
}
#[cfg(windows)]
#[test]
fn os_unsupported_tool_message_still_backs_the_windows_e2e_fixture() {
let msg = os_unsupported_tool_message("mint")
.expect("docker-slim provides mint and its os list omits windows");
for expected in [
"docker-slim",
"not available on windows",
"mint",
"linux",
"macos",
] {
assert!(msg.contains(expected), "{expected:?} missing from {msg:?}");
}
}
#[test]
fn windows_file_shim_body_recovers_the_arguments_cmd_destroys() {
let body = windows_file_shim_body("gh");
let lines: Vec<&str> = body.lines().collect();
let enable = lines
.iter()
.position(|l| l.contains("EnableDelayedExpansion"))
.unwrap();
let capture = lines.iter().position(|l| l.contains("%~f0")).unwrap();
assert!(capture < enable, "{body}");
assert!(
body.contains(r#"set "__MISE_RAW_CMDLINE=!CMDCMDLINE!""#),
"{body}"
);
assert!(!body.contains("%CMDCMDLINE%"), "{body}");
let guard = lines
.iter()
.position(|l| l.contains("%__MISE_SHIM_PATH%"))
.unwrap();
assert!(guard < enable, "{body}");
assert!(
body.contains("recursive shim invocation detected for gh"),
"{body}"
);
assert!(body.contains("exit /b 1"), "{body}");
let run = format!("mise x -- gh {} %*", env::LAUNCHER_ARGS_SENTINEL);
assert_eq!(lines.iter().filter(|l| **l == run).count(), 2, "{body}");
assert!(body.contains("goto mise_shim_fallback"), "{body}");
assert!(body.contains("exit !ERRORLEVEL!"), "{body}");
}
#[test]
fn windows_file_shim_body_clears_the_launcher_variables_when_it_declines() {
let body = windows_file_shim_body("gh");
let after: Vec<&str> = body
.lines()
.skip_while(|l| *l != ":mise_shim_fallback")
.collect();
assert!(!after.is_empty(), "{body}");
assert!(after.contains(&r#"set "__MISE_RAW_CMDLINE=""#), "{body}");
assert!(after.contains(&r#"set "__MISE_LAUNCHER=""#), "{body}");
}
#[test]
fn windows_file_shim_body_is_crlf_terminated() {
let body = windows_file_shim_body("gh");
assert!(body.ends_with("\r\n"));
assert_eq!(body.matches('\n').count(), body.matches("\r\n").count());
}
#[cfg(windows)]
#[test]
fn bash_shim_script_includes_wsl_guard() {
let script = bash_shim_script("gh");
assert!(script.starts_with("#!/bin/bash"));
assert!(script.contains("WSL_DISTRO_NAME"));
assert!(script.contains("WSL_INTEROP"));
assert!(script.contains("/proc/sys/fs/binfmt_misc/WSLInterop"));
assert!(script.contains(r#"shim_dir=$(cd -- "$(dirname -- "$0")" && pwd -P)"#));
assert!(script.contains("set -f"));
assert!(script.contains(r#"shim_path="$shim_dir/${0##*/}""#));
assert!(script.contains(r#"export __MISE_SHIM_PATH="$shim_path""#));
assert!(script.contains("recursive shim invocation detected"));
assert!(script.contains(r#"exec gh "$@""#));
assert!(script.contains(r#"exec mise x -- gh "$@""#));
}
#[test]
fn list_executables_in_dir_skips_dotfiles() {
let dir = tempfile::tempdir().unwrap();
let visible_name = if cfg!(windows) {
"ffmpeg.exe"
} else {
"ffmpeg"
};
let visible = dir.path().join(visible_name);
let hidden = dir.path().join(".librsvg-post-link.exe");
fs::write(&visible, "").unwrap();
fs::write(&hidden, "").unwrap();
file::make_executable(&visible).unwrap();
file::make_executable(&hidden).unwrap();
let bins = list_executables_in_dir(dir.path()).unwrap();
assert!(bins.contains(visible_name));
assert!(!bins.contains(".librsvg-post-link.exe"));
}
#[cfg(target_os = "linux")]
#[test]
fn list_executables_in_dir_skips_non_utf8_names() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let dir = tempfile::tempdir().unwrap();
let non_utf8 = dir.path().join(OsString::from_vec(vec![0xff]));
fs::write(&non_utf8, "").unwrap();
file::make_executable(&non_utf8).unwrap();
let bins = list_executables_in_dir(dir.path()).unwrap();
assert!(bins.is_empty());
}
#[test]
fn shim_version_stale_detects_version_changes() {
assert!(shim_version_stale(Some("2026.5.13"), "2026.5.16", "exe"));
assert!(shim_version_stale(
Some("2026.5.13"),
"2026.5.16",
"hardlink"
));
assert!(shim_version_stale(Some("2026.5.13"), "2026.5.16", "file"));
assert!(!shim_version_stale(Some("2026.5.16"), "2026.5.16", "exe"));
assert!(!shim_version_stale(Some("2026.5.16"), "2026.5.16", "file"));
assert!(!shim_version_stale(Some("2026.5.16\n"), "2026.5.16", "exe"));
assert!(shim_version_stale(None, "2026.5.16", "exe"));
assert!(shim_version_stale(None, "2026.5.16", "file"));
assert!(!shim_version_stale(
Some("2026.5.13"),
"2026.5.16",
"symlink"
));
}
}