use crate::request_exit;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{
collections::{BTreeMap, BTreeSet, HashSet},
sync::atomic::Ordering,
};
use crate::backend::Backend;
use crate::cli::args::{BackendArg, ToolArg};
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;
#[cfg(windows)]
use indoc::formatdoc;
use itertools::Itertools;
use path_absolutize::Absolutize;
use tokio::task::JoinSet;
#[cfg(any(windows, test))]
const NATIVE_SHIM_MARKER: &[u8] = include_bytes!("../crates/mise-shim/native-shim-marker");
const GENERATED_SHELL_SHIM_HEADER: &str = "#!/bin/sh\n# mise generated shim\n";
#[cfg(any(windows, test))]
const GENERATED_WINDOWS_CMD_SHIM_HEADER: &str = "@echo off\r\nrem mise generated shim\r\n";
#[cfg(any(windows, test))]
const GENERATED_WINDOWS_BASH_SHIM_HEADER: &str = "#!/bin/bash\n# mise generated shim\n";
const SHIM_SCRIPT_INSPECTION_LIMIT: u64 = 16 * 1024;
pub(crate) const TASK_TOOL_ARGS_ENV: &str = "__MISE_TASK_TOOL_ARGS";
#[derive(serde::Deserialize, serde::Serialize)]
struct TaskToolArg {
backend: String,
version: Option<String>,
options: crate::toolset::ToolVersionOptions,
}
pub(crate) fn task_tool_args_env(tools: &[ToolArg]) -> Result<Option<String>> {
let tools = tools
.iter()
.map(|tool| TaskToolArg {
backend: tool.ba.short.clone(),
version: tool.version.clone(),
options: tool
.tvr
.as_ref()
.map(|request| request.options())
.unwrap_or_default(),
})
.collect_vec();
if tools.is_empty() {
Ok(None)
} else {
Ok(Some(serde_json::to_string(&tools)?))
}
}
pub(crate) fn task_tool_args_from_env() -> Result<Vec<ToolArg>> {
let Ok(serialized) = env::var(TASK_TOOL_ARGS_ENV) else {
return Ok(vec![]);
};
serde_json::from_str::<Vec<TaskToolArg>>(&serialized)?
.into_iter()
.map(|tool| {
let input = tool.version.as_ref().map_or_else(
|| tool.backend.clone(),
|version| format!("{}@{version}", tool.backend),
);
let mut arg: ToolArg = input.parse()?;
if !tool.options.is_empty() {
Arc::make_mut(&mut arg.ba).set_opts(Some(tool.options));
}
arg.tvr = arg
.version
.as_ref()
.map(|version| {
crate::toolset::ToolRequest::new(
arg.ba.clone(),
version,
crate::toolset::ToolSource::Argument,
)
})
.transpose()?;
Ok(arg)
})
.collect()
}
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 shim_name = command_name_without_exe_suffix(bin_name);
let is_usage = if cfg!(windows) {
shim_name.eq_ignore_ascii_case("usage")
} else {
shim_name == "usage"
};
let completion_offline = is_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 task_tools = task_tool_args_from_env()?;
let mut ts = ToolsetBuilder::new()
.with_args(&task_tools)
.with_resolve_options(resolve_options)
.build(config)
.await?;
let wrappers = load_command_wrappers(
&config.config_files,
ts.versions.values().flat_map(|versions| &versions.requests),
)?;
validate_wrapper_names(wrappers.keys())?;
let wrapper = if cfg!(macos) {
wrappers
.iter()
.find(|(name, _)| command_names_eq(name, shim_name))
.map(|(_, wrapper)| wrapper)
} else {
wrappers.get(shim_name)
};
if let Some(wrapper) = wrapper {
if command_names_eq(wrapper.command(), shim_name) {
bail!("command wrapper for {shim_name} 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, shim_name)
.await?
{
for tv in ts
.install_missing_bin(config, shim_name)
.await?
.unwrap_or_default()
{
let p = tv.backend()?;
if let Some(bin) =
backend_which_shim(p.as_ref(), config, &tv, shim_name, bin_name).await?
{
trace!(
"shim[{bin_name}] REGISTRY ToolVersion: {tv} bin: {bin}",
bin = display_path(&bin)
);
return Ok((bin, ts, None));
}
}
}
for lookup_name in [shim_name, bin_name].into_iter().unique() {
if let Some((p, tv)) = ts.which(config, lookup_name).await
&& let Some(bin) = p.which(config, &tv, lookup_name).await?
{
trace!(
"shim[{bin_name}] ToolVersion: {tv} bin: {bin}",
bin = display_path(&bin)
);
return Ok((bin, ts, None));
}
}
if !completion_offline && ts.has_missing_lazy_bin_provider(config, shim_name).await? {
for tv in ts
.install_missing_lazy_bin(config, shim_name)
.await?
.unwrap_or_default()
{
let backend = tv.backend()?;
if let Some(bin) =
backend_which_shim(backend.as_ref(), config, &tv, shim_name, bin_name).await?
{
trace!(
"shim[{bin_name}] LAZY 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, shim_name)
.await?
.unwrap_or_default()
{
let p = tv.backend()?;
if let Some(bin) =
backend_which_shim(p.as_ref(), config, &tv, shim_name, 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 mut tvs = ts.list_rtvs_with_bin(config, shim_name).await?;
if tvs.is_empty() && shim_name != bin_name {
tvs = ts.list_rtvs_with_bin(config, bin_name).await?;
}
match err_no_version_set(config, ts, shim_name, tvs).await {
Ok(_) => unreachable!("err_no_version_set always returns an error"),
Err(err) => Err(err),
}
}
async fn backend_which_shim(
backend: &dyn Backend,
config: &Arc<Config>,
tv: &ToolVersion,
shim_name: &str,
bin_name: &str,
) -> Result<Option<PathBuf>> {
for lookup_name in [shim_name, bin_name].into_iter().unique() {
if let Some(bin) = backend.which(config, tv, lookup_name).await? {
return Ok(Some(bin));
}
}
Ok(None)
}
#[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,
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ShimScope {
User,
System,
Both,
}
pub(crate) fn shim_farm_dirs() -> Vec<PathBuf> {
let user_shims = dirs::shims();
let system_shims = dirs::system_shims();
let mut dirs = vec![user_shims];
if system_shims.is_dir() && !file::storage_paths_eq(&dirs[0], &system_shims) {
dirs.push(system_shims);
}
dirs
}
pub(crate) fn ensure_lazy_shims(missing: &[ToolVersion]) -> Result<()> {
let mut bins_by_dir = BTreeMap::<PathBuf, Vec<String>>::new();
let mut lazy_bins_error = None;
for tv in missing {
if tv.request.options().lazy != Some(true) {
continue;
}
let bins = match tv.request.lazy_bins() {
Ok(Some(bins)) => bins,
Ok(None) => continue,
Err(err) => {
lazy_bins_error.get_or_insert(err);
continue;
}
};
let shims_dir = if shim_scope_contains_request(ShimScope::System, &tv.request) {
dirs::system_shims()
} else {
dirs::shims()
};
bins_by_dir.entry(shims_dir).or_default().extend(bins);
}
if !bins_by_dir.is_empty() {
let mise_bin = mise_bin_for_shims().absolutize()?.into_owned();
for (shims_dir, bins) in bins_by_dir {
let shims = bins
.iter()
.flat_map(|bin| platform_shim_names(&mise_bin, bin))
.collect::<BTreeSet<String>>();
match write_bootstrap_shims(&mise_bin, &shims_dir, &shims, false)? {
None => {}
Some(err) => {
debug!(
"skipping bootstrap shims in {}: {err:#}",
display_path(&shims_dir)
);
}
}
}
}
if let Some(err) = lazy_bins_error {
Err(err)
} else {
Ok(())
}
}
fn write_bootstrap_shims(
mise_bin: &Path,
shims_dir: &Path,
shims: &BTreeSet<String>,
_prune_stale_windows_variants: bool,
) -> Result<Option<eyre::Report>> {
if let Err(err) = file::create_dir_all(shims_dir) {
if is_permission_denied(&err) {
return Ok(Some(err));
}
return Err(err);
}
let _lock = LockFile::new(shims_dir).lock()?;
#[cfg(windows)]
if _prune_stale_windows_variants {
remove_stale_windows_shim_variants(shims_dir, shims)?;
}
#[cfg(windows)]
validate_windows_shim_source(mise_bin)?;
for shim in shims {
let path = shims_dir.join(shim);
if !path.exists()
&& let Err(err) = add_shim(mise_bin, &path, shim)
{
if is_permission_denied(&err) {
return Ok(Some(err));
}
return Err(err);
}
}
Ok(None)
}
#[cfg(windows)]
fn remove_stale_windows_shim_variants(shims_dir: &Path, desired: &BTreeSet<String>) -> Result<()> {
let stems = desired
.iter()
.map(|shim| {
Path::new(shim)
.with_extension("")
.to_string_lossy()
.to_string()
})
.collect::<BTreeSet<_>>();
for stem in stems {
for variant in [stem.clone(), format!("{stem}.cmd"), format!("{stem}.exe")] {
if !desired.contains(&variant) {
remove_shim_with_rename_fallback(&shims_dir.join(variant))?;
}
}
}
Ok(())
}
#[cfg(windows)]
fn validate_windows_shim_source(mise_bin: &Path) -> Result<()> {
match effective_shim_mode(mise_bin).as_ref() {
"exe" => {
let source =
find_mise_shim_bin(mise_bin).ok_or_else(|| eyre!("mise-shim.exe not found"))?;
fs::File::open(&source)
.wrap_err_with(|| eyre!("Failed to open shim source {}", display_path(&source)))?;
}
"hardlink" => {
fs::metadata(mise_bin).wrap_err_with(|| {
eyre!("Failed to access shim source {}", display_path(mise_bin))
})?;
}
_ => {}
}
Ok(())
}
fn is_permission_denied(err: &eyre::Report) -> bool {
err.chain().any(|cause| {
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
matches!(
io.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem
)
})
})
}
pub(crate) async fn reshim_for(
config: &Arc<Config>,
ts: &Toolset,
force: bool,
requested_scope: ShimScope,
) -> Result<()> {
let user_shims = dirs::shims();
let system_shims = dirs::system_shims();
let collocated = file::storage_paths_eq(&user_shims, &system_shims);
let scope = if collocated {
ShimScope::Both
} else {
requested_scope
};
let shims_dir = match requested_scope {
ShimScope::User | ShimScope::Both => user_shims,
ShimScope::System => system_shims,
};
let _lock = LockFile::new(&shims_dir)
.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 = shims_dir.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 = shims_dir.join(".version");
let prev = fs::read_to_string(&version_file).ok();
shim_version_stale(prev.as_deref(), shim_version, &shim_mode)
};
let full_rebuild = force || shim_mode_changed || shim_version_changed;
file::create_dir_all(&shims_dir)?;
let dedicated = is_dedicated_shims_dir(&shims_dir);
let (desired, shims_to_stage, known_owned, prune_entries) = if full_rebuild {
let desired = get_desired_shims(config, &mise_bin, ts, scope, force).await?;
let shims_to_stage = desired.iter().cloned().collect();
if dedicated {
(desired, shims_to_stage, HashSet::new(), HashSet::new())
} else {
let actual = get_actual_shims(&mise_bin, &shims_dir).await?;
let prune_entries = actual.owned.difference(&desired).cloned().collect();
(desired, shims_to_stage, actual.owned, prune_entries)
}
} else {
let diffs = get_shim_diffs(config, &mise_bin, ts, &shims_dir, scope, false).await?;
(
diffs.desired,
diffs.missing,
diffs.owned,
diffs.extra.into_iter().collect(),
)
};
let staging = stage_shim_farm(
&shims_dir,
&mise_bin,
scope,
&shims_to_stage,
&shim_mode,
shim_version,
)
.await?;
publish_staged_shim_farm(
&shims_dir,
&mise_bin,
staging,
desired,
known_owned,
prune_entries,
full_rebuild && dedicated,
)?;
if matches!(requested_scope, ShimScope::User | ShimScope::Both) {
sync_command_wrapper_shims(config, ts, &mise_bin, full_rebuild)?;
}
Ok(())
}
async fn stage_shim_farm(
shims_dir: &Path,
mise_bin: &Path,
scope: ShimScope,
desired: &BTreeSet<String>,
shim_mode: &str,
shim_version: &str,
) -> Result<tempfile::TempDir> {
let staging = tempfile::Builder::new()
.prefix(".mise-shims-stage-")
.tempdir_in(shims_dir)
.wrap_err_with(|| {
format!(
"failed to create shim staging directory in {}",
display_path(shims_dir)
)
})?;
write_shim_metadata(staging.path(), shim_mode, shim_version)?;
for shim in desired {
add_shim(mise_bin, &staging.path().join(shim), shim)?;
}
add_plugin_shims(staging.path(), scope).await?;
Ok(staging)
}
fn write_shim_metadata(shims_dir: &Path, shim_mode: &str, shim_version: &str) -> Result<()> {
if cfg!(windows) {
file::write(shims_dir.join(".mode"), shim_mode)?;
file::write(shims_dir.join(".version"), shim_version)?;
}
Ok(())
}
async fn add_plugin_shims(shims_dir: &Path, scope: ShimScope) -> Result<()> {
if !matches!(scope, ShimScope::User | ShimScope::Both) {
return Ok(());
}
let mut jset = JoinSet::new();
for plugin in backend::list() {
let shims_dir = shims_dir.to_path_buf();
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 = shims_dir.join(bin_name);
make_shim(&bin.path(), &symlink_path).await?;
}
}
Ok(())
});
}
jset.join_all()
.await
.into_iter()
.collect::<Result<Vec<_>>>()?;
Ok(())
}
fn publish_staged_shim_farm(
shims_dir: &Path,
mise_bin: &Path,
staging: tempfile::TempDir,
mut desired: HashSet<String>,
known_owned: HashSet<String>,
prune_entries: HashSet<String>,
prune_unmanaged: bool,
) -> Result<()> {
let mut entries = staging
.path()
.read_dir()
.wrap_err_with(|| {
format!(
"failed to read staged shim directory: {}",
display_path(staging.path())
)
})?
.collect::<std::io::Result<Vec<_>>>()?;
entries.sort_by_key(|entry| is_hidden_shim_name(&entry.file_name()));
desired.extend(
entries
.iter()
.filter_map(|entry| entry.file_name().into_string().ok()),
);
for entry in entries {
let source = entry.path();
let destination = shims_dir.join(entry.file_name());
let destination_exists = destination.exists() || destination.is_symlink();
let destination_owned = is_hidden_shim_name(&entry.file_name())
|| known_owned.contains(&entry.file_name().to_string_lossy().into_owned())
|| (destination_exists
&& (is_mise_shim(&destination, mise_bin)?
|| symlink_target_names_mise(&destination)?));
if destination_exists
&& !prune_unmanaged
&& !destination_owned
&& !files_identical(&source, &destination).unwrap_or(false)
{
warn!(
"not replacing unmanaged file in shims directory: {}",
display_path(&destination)
);
continue;
}
if cfg!(windows) && destination_exists {
remove_shim_with_rename_fallback(&destination)?;
}
fs::rename(&source, &destination).wrap_err_with(|| {
format!(
"failed to publish shim {} to {}",
display_path(&source),
display_path(&destination)
)
})?;
}
for shim in list_shims_in(shims_dir)?.difference(&desired) {
let path = shims_dir.join(shim);
if prune_unmanaged || prune_entries.contains(shim) {
if cfg!(windows) {
remove_shim_with_rename_fallback(&path)?;
} else {
file::remove_all(&path)?;
}
}
}
if let Err(err) = staging.close() {
warn!("failed to remove shim staging directory: {err}");
}
Ok(())
}
fn is_dedicated_shims_dir(path: &Path) -> bool {
matches_unredirected_dedicated_dir(path, &dirs::DATA.join("shims"))
|| matches_unredirected_dedicated_dir(path, &env::MISE_SYSTEM_DATA_DIR.join("shims"))
}
fn matches_unredirected_dedicated_dir(path: &Path, dedicated: &Path) -> bool {
if !file::paths_eq(path, dedicated) {
return false;
}
let Some((parent, file_name)) = path.parent().zip(path.file_name()) else {
return false;
};
match (dunce::canonicalize(path), dunce::canonicalize(parent)) {
(Ok(resolved), Ok(resolved_parent)) => {
file::paths_eq(&resolved, &resolved_parent.join(file_name))
}
_ => false,
}
}
fn files_identical(a: &Path, b: &Path) -> Result<bool> {
if a.is_symlink() || b.is_symlink() {
return Ok(a.is_symlink() && b.is_symlink() && fs::read_link(a)? == fs::read_link(b)?);
}
if !a.is_file() || !b.is_file() {
return Ok(false);
}
if fs::metadata(a)?.len() != fs::metadata(b)?.len() {
return Ok(false);
}
Ok(fs::read(a)? == fs::read(b)?)
}
fn read_file_prefix(path: &Path) -> Result<Vec<u8>> {
let mut contents = Vec::new();
fs::File::open(path)?
.take(SHIM_SCRIPT_INSPECTION_LIMIT)
.read_to_end(&mut contents)?;
Ok(contents)
}
fn is_mise_dispatcher_name(name: &str) -> bool {
if cfg!(windows) {
name.eq_ignore_ascii_case("mise") || name.eq_ignore_ascii_case("mise.exe")
} else {
name == "mise"
}
}
fn resolved_symlink_target(path: &Path) -> Result<Option<PathBuf>> {
if !path.is_symlink() {
return Ok(None);
}
let target = fs::read_link(path)?;
Ok(Some(if target.is_absolute() {
target
} else {
path.parent().unwrap_or_else(|| Path::new(".")).join(target)
}))
}
fn symlink_target_names_mise(path: &Path) -> Result<bool> {
Ok(resolved_symlink_target(path)?.is_some_and(|target| {
target
.file_name()
.and_then(|name| name.to_str())
.is_some_and(is_mise_dispatcher_name)
}))
}
fn is_mise_shim(path: &Path, mise_bin: &Path) -> Result<bool> {
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(is_mise_dispatcher_name)
{
return Ok(false);
}
if path.is_symlink() {
let target = resolved_symlink_target(path)?.expect("symlink target");
return Ok(file::paths_eq(
&file::canonicalize_or_self(&target),
&file::canonicalize_or_self(mise_bin),
));
}
#[cfg(windows)]
{
if !path.is_file() {
return Ok(false);
}
let parent = path.parent().unwrap_or_else(|| Path::new("."));
if is_dedicated_shims_dir(parent) {
return Ok(true);
}
let is_script = path.extension().is_none()
|| path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("cmd"));
let contents = if is_script {
read_file_prefix(path).unwrap_or_default()
} else {
Vec::new()
};
if is_generated_shell_shim(&contents)
|| is_generated_windows_file_shim_contents(path, &contents)
{
return Ok(true);
}
let matches_mise = files_identical(path, mise_bin).unwrap_or(false);
let matches_launcher = find_mise_shim_bin(mise_bin)
.is_some_and(|launcher| files_identical(path, &launcher).unwrap_or(false));
if matches_mise || matches_launcher {
return Ok(true);
}
let contents = if is_script {
contents
} else {
fs::read(path).unwrap_or_default()
};
return Ok(has_mise_native_shim_fingerprint(&contents));
}
#[cfg(not(windows))]
{
if !path.is_file() {
return Ok(false);
}
Ok(is_generated_shell_shim(
&read_file_prefix(path).unwrap_or_default(),
))
}
}
#[cfg(any(windows, test))]
fn bytes_contain(haystack: &[u8], needle: &[u8]) -> bool {
!needle.is_empty()
&& haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn is_generated_shell_shim(contents: &[u8]) -> bool {
contents.starts_with(GENERATED_SHELL_SHIM_HEADER.as_bytes()) || is_legacy_plugin_shim(contents)
}
fn is_legacy_plugin_shim(contents: &[u8]) -> bool {
let Ok(contents) = std::str::from_utf8(contents) else {
return false;
};
let mut lines = contents.lines();
lines.next() == Some("#!/bin/sh")
&& lines
.next()
.is_some_and(|line| line.starts_with("export ASDF_DATA_DIR=") && line.len() > 21)
&& lines
.next()
.is_some_and(|line| line.starts_with("export PATH=\"") && line.ends_with(":$PATH\""))
&& lines
.next()
.is_some_and(|line| line.starts_with("mise x -- ") && line.ends_with(" \"$@\""))
&& lines.next().is_none()
}
#[cfg(any(windows, test))]
fn has_mise_native_shim_fingerprint(contents: &[u8]) -> bool {
bytes_contain(contents, NATIVE_SHIM_MARKER)
|| (bytes_contain(
contents,
b"mise-shim: failed to determine executable path",
) && bytes_contain(contents, b"mise-shim: failed to execute mise"))
|| (bytes_contain(contents, b"__MISE_SHIM_PATH")
&& bytes_contain(contents, b"recursive shim invocation detected")
&& bytes_contain(contents, b"mise x --"))
}
#[cfg(test)]
fn is_generated_windows_file_shim(path: &Path) -> bool {
fs::read(path).is_ok_and(|contents| is_generated_windows_file_shim_contents(path, &contents))
}
#[cfg(any(windows, test))]
fn is_generated_windows_file_shim_contents(path: &Path, contents: &[u8]) -> bool {
let Some(name) = path.file_stem().and_then(|name| name.to_str()) else {
return false;
};
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("cmd"))
{
contents.starts_with(GENERATED_WINDOWS_CMD_SHIM_HEADER.as_bytes())
|| contents == windows_file_shim_body(name).as_bytes()
|| is_legacy_windows_cmd_shim(contents)
} else if path.extension().is_none() {
if contents.starts_with(GENERATED_WINDOWS_BASH_SHIM_HEADER.as_bytes()) {
return true;
}
#[cfg(windows)]
return contents == bash_shim_script(name).as_bytes();
#[cfg(not(windows))]
return false;
} else {
false
}
}
#[cfg(any(windows, test))]
fn is_legacy_windows_cmd_shim(contents: &[u8]) -> bool {
let normalized = String::from_utf8_lossy(contents).replace("\r\n", "\n");
matches!(
normalized.as_str(),
"@echo off\nsetlocal\nmise x -- %*\n" | "@echo off\nsetlocal\nmise x -- %*"
)
}
pub(crate) fn ensure_command_wrapper_shims(config: &Config, ts: &Toolset) -> Result<()> {
let wrappers = load_command_wrappers(
&config.config_files,
ts.versions.values().flat_map(|versions| &versions.requests),
)?;
validate_wrapper_names(wrappers.keys())?;
if wrappers.is_empty() {
return Ok(());
}
let mise_bin = mise_bin_for_shims().absolutize()?.into_owned();
let shims = wrappers
.keys()
.flat_map(|name| platform_shim_names(&mise_bin, name))
.collect();
if let Some(error) =
write_bootstrap_shims(&mise_bin, &dirs::COMMAND_WRAPPERS, &shims, cfg!(windows))?
{
return Err(error);
}
Ok(())
}
fn sync_command_wrapper_shims(
config: &Config,
ts: &Toolset,
mise_bin: &Path,
force: bool,
) -> Result<()> {
let wrappers = load_command_wrappers(
&config.config_files,
ts.versions.values().flat_map(|versions| &versions.requests),
)?;
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(())
}
pub(crate) fn command_names_eq(a: &str, b: &str) -> bool {
if cfg!(macos) {
a.to_lowercase() == b.to_lowercase()
} else {
a == b
}
}
pub(crate) fn command_name_without_exe_suffix(bin_name: &str) -> &str {
let suffix = std::env::consts::EXE_SUFFIX;
if suffix.is_empty() {
return bin_name;
}
let suffix_start = bin_name.len().saturating_sub(suffix.len());
match (bin_name.get(..suffix_start), bin_name.get(suffix_start..)) {
(Some(name), Some(actual_suffix)) if actual_suffix.eq_ignore_ascii_case(suffix) => name,
_ => bin_name,
}
}
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> {
let real_bin = dunce::canonicalize(mise_bin).unwrap_or_else(|_| mise_bin.to_path_buf());
if let Some(parent) = real_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
# mise generated shim
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",
"rem mise generated shim",
"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>,
owned: HashSet<String>,
}
struct ActualShims {
current: HashSet<String>,
dedicated_present: HashSet<String>,
owned: HashSet<String>,
occupied: HashSet<String>,
repairable: HashSet<String>,
}
fn calculate_shim_diffs(
actual: &ActualShims,
desired: &HashSet<String>,
dedicated: bool,
) -> (BTreeSet<String>, BTreeSet<String>) {
let (missing, extra) = if dedicated {
(
desired
.difference(&actual.dedicated_present)
.cloned()
.collect(),
actual
.dedicated_present
.difference(desired)
.cloned()
.collect(),
)
} else {
(
desired
.iter()
.filter(|name| {
!actual.current.contains(*name)
&& (!actual.occupied.contains(*name)
|| actual.owned.contains(*name)
|| actual.repairable.contains(*name))
})
.cloned()
.collect(),
actual.owned.difference(desired).cloned().collect(),
)
};
(missing, extra)
}
pub(crate) async fn get_shim_diffs(
config: &Arc<Config>,
mise_bin: impl AsRef<Path>,
toolset: &Toolset,
shims_dir: &Path,
scope: ShimScope,
strict_lazy_bins: bool,
) -> Result<ShimDiffs> {
let mise_bin = mise_bin.as_ref();
let (actual_shims, desired_shims) = tokio::join!(
get_actual_shims(mise_bin, shims_dir),
get_desired_shims(config, mise_bin, toolset, scope, strict_lazy_bins)
);
let (actual_shims, desired_shims) = (actual_shims?, desired_shims?);
let (missing, extra) = calculate_shim_diffs(
&actual_shims,
&desired_shims,
is_dedicated_shims_dir(shims_dir),
);
time!("get_shim_diffs sizes: ({},{})", missing.len(), extra.len());
Ok(ShimDiffs {
missing,
extra,
desired: desired_shims,
owned: actual_shims.owned,
})
}
async fn get_actual_shims(mise_bin: impl AsRef<Path>, shims_dir: &Path) -> Result<ActualShims> {
let mise_bin = mise_bin.as_ref();
let occupied = list_shims_in(shims_dir)?;
let mut current = HashSet::new();
let mut dedicated_present = HashSet::new();
let mut owned = HashSet::new();
let mut repairable = HashSet::new();
for bin in &occupied {
let path = shims_dir.join(bin);
if is_mise_shim(&path, mise_bin).unwrap_or(false) {
owned.insert(bin.clone());
if is_current_owned_mise_shim(&path, mise_bin).unwrap_or(false) {
current.insert(bin.clone());
}
} else if symlink_target_names_mise(&path).unwrap_or(false) {
repairable.insert(bin.clone());
}
if !path.is_symlink() || current.contains(bin) {
dedicated_present.insert(bin.clone());
}
}
Ok(ActualShims {
current,
dedicated_present,
owned,
occupied,
repairable,
})
}
fn is_current_owned_mise_shim(path: &Path, mise_bin: &Path) -> Result<bool> {
if !path.is_symlink() {
return Ok(true);
}
let target = resolved_symlink_target(path)?.expect("symlink target");
Ok(file::paths_eq(&target, mise_bin))
}
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_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)
}
fn shim_scope_contains_install(scope: ShimScope, install_path: &Path) -> bool {
if scope == ShimScope::Both {
return true;
}
let system_installs = Settings::get().system_installs_dir().to_path_buf();
if file::storage_paths_eq(&system_installs, &dirs::INSTALLS) {
return true;
}
let is_system = install_path.starts_with(system_installs);
matches!(scope, ShimScope::System) == is_system
}
fn shim_scope_contains_request(scope: ShimScope, request: &crate::toolset::ToolRequest) -> bool {
if scope == ShimScope::Both {
return true;
}
let is_system = request.source().path().is_some_and(|path| {
crate::config::provenance::ConfigProvenance::from_path(path).scope()
== crate::config::provenance::ConfigFileScope::System
});
matches!(scope, ShimScope::System) == is_system
}
async fn get_desired_shims(
config: &Arc<Config>,
mise_bin: &Path,
toolset: &Toolset,
scope: ShimScope,
strict_lazy_bins: bool,
) -> Result<HashSet<String>> {
let _mise_bin = mise_bin; let mut shims = HashSet::new();
for (t, tv) in toolset.list_installed_versions(config).await? {
if !shim_scope_contains_install(scope, &tv.install_path()) {
continue;
}
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)),
);
}
for request in toolset.list_current_requests() {
if !shim_scope_contains_request(scope, request) {
continue;
}
match request.lazy_bins() {
Ok(Some(bins)) => shims.extend(
bins.into_iter()
.flat_map(|bin| platform_shim_names(_mise_bin, &bin)),
),
Ok(None) => {}
Err(err) if strict_lazy_bins => return Err(err),
Err(err) => warn!("Skipping invalid lazy shim declaration: {err:#}"),
}
}
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,
format!(
"{GENERATED_SHELL_SHIM_HEADER}export ASDF_DATA_DIR={data_dir}\nexport PATH=\"{fake_asdf_dir}:$PATH\"\nmise x -- {target} \"$@\"\n",
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(windows)]
#[test]
fn command_wrappers_remove_stale_windows_shim_variants() {
let dir = tempfile::tempdir().unwrap();
for shim in ["cargo", "cargo.cmd", "cargo.exe", "cargo.exe.old"] {
fs::write(dir.path().join(shim), "shim").unwrap();
}
let desired = BTreeSet::from(["cargo".to_string(), "cargo.cmd".to_string()]);
remove_stale_windows_shim_variants(dir.path(), &desired).unwrap();
assert!(dir.path().join("cargo").exists());
assert!(dir.path().join("cargo.cmd").exists());
assert!(!dir.path().join("cargo.exe").exists());
assert!(!dir.path().join("cargo.exe.old").exists());
}
#[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());
}
#[cfg(windows)]
#[test]
fn windows_shim_command_names_drop_exe_suffix_case_insensitively() {
assert_eq!(command_name_without_exe_suffix("dummy.exe"), "dummy");
assert_eq!(command_name_without_exe_suffix("DUMMY.EXE"), "DUMMY");
assert_eq!(
command_name_without_exe_suffix("python3.12.exe"),
"python3.12"
);
assert_eq!(command_name_without_exe_suffix("dummy.cmd"), "dummy.cmd");
}
#[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());
}
#[test]
fn windows_file_shim_detection_uses_stable_markers_and_legacy_body() {
let dir = tempfile::tempdir().unwrap();
let shim = dir.path().join("gh.cmd");
fs::write(&shim, windows_file_shim_body("gh")).unwrap();
assert!(is_generated_windows_file_shim(&shim));
fs::write(
&shim,
format!("{GENERATED_WINDOWS_CMD_SHIM_HEADER}echo future body\r\n"),
)
.unwrap();
assert!(is_generated_windows_file_shim(&shim));
fs::write(&shim, "@echo off\r\nsetlocal\r\nmise x -- %*\r\n").unwrap();
assert!(is_generated_windows_file_shim(&shim));
fs::write(&shim, "@echo off\r\necho user script\r\n").unwrap();
assert!(!is_generated_windows_file_shim(&shim));
let bash_shim = dir.path().join("gh");
fs::write(
&bash_shim,
format!("{GENERATED_WINDOWS_BASH_SHIM_HEADER}echo future body\n"),
)
.unwrap();
assert!(is_generated_windows_file_shim(&bash_shim));
}
#[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"));
}
#[test]
fn staged_shim_publication_preserves_unmanaged_and_modified_files() {
let live = tempfile::tempdir().unwrap();
let mise_bin = live.path().join("mise");
fs::write(&mise_bin, "mise").unwrap();
let unmanaged = live.path().join("unmanaged");
fs::write(&unmanaged, "from another package manager").unwrap();
file::make_executable(&unmanaged).unwrap();
let staging = tempfile::Builder::new()
.prefix(".mise-shims-stage-")
.tempdir_in(live.path())
.unwrap();
fs::write(staging.path().join("unmanaged"), "mise shim").unwrap();
fs::write(
staging.path().join("owned"),
"#!/bin/sh\n# mise generated shim\nmise x -- owned \"$@\"\n",
)
.unwrap();
publish_staged_shim_farm(
live.path(),
&mise_bin,
staging,
HashSet::new(),
HashSet::new(),
HashSet::new(),
false,
)
.unwrap();
assert_eq!(
fs::read_to_string(&unmanaged).unwrap(),
"from another package manager"
);
assert_eq!(
fs::read_to_string(live.path().join("owned")).unwrap(),
"#!/bin/sh\n# mise generated shim\nmise x -- owned \"$@\"\n"
);
fs::write(live.path().join("owned"), "user replacement").unwrap();
let empty_staging = tempfile::Builder::new()
.prefix(".mise-shims-stage-")
.tempdir_in(live.path())
.unwrap();
publish_staged_shim_farm(
live.path(),
&mise_bin,
empty_staging,
HashSet::new(),
HashSet::new(),
HashSet::new(),
false,
)
.unwrap();
assert_eq!(
fs::read_to_string(live.path().join("owned")).unwrap(),
"user replacement"
);
}
#[test]
fn staged_shim_publication_removes_unchanged_owned_shims() {
let live = tempfile::tempdir().unwrap();
let mise_bin = live.path().join("mise");
fs::write(&mise_bin, "mise").unwrap();
let staging = tempfile::Builder::new()
.prefix(".mise-shims-stage-")
.tempdir_in(live.path())
.unwrap();
fs::write(
staging.path().join("owned"),
"#!/bin/sh\n# mise generated shim\nmise x -- owned \"$@\"\n",
)
.unwrap();
publish_staged_shim_farm(
live.path(),
&mise_bin,
staging,
HashSet::new(),
HashSet::new(),
HashSet::new(),
false,
)
.unwrap();
let empty_staging = tempfile::Builder::new()
.prefix(".mise-shims-stage-")
.tempdir_in(live.path())
.unwrap();
publish_staged_shim_farm(
live.path(),
&mise_bin,
empty_staging,
HashSet::new(),
HashSet::from(["owned".to_string()]),
HashSet::from(["owned".to_string()]),
false,
)
.unwrap();
assert!(!live.path().join("owned").exists());
}
#[test]
fn staged_shim_publication_prunes_unmanaged_files_in_dedicated_farm() {
let live = tempfile::tempdir().unwrap();
let mise_bin = live.path().join("mise");
fs::write(&mise_bin, "mise").unwrap();
let orphan = live.path().join("orphan");
fs::write(&orphan, "not a mise shim").unwrap();
file::make_executable(&orphan).unwrap();
let staging = tempfile::Builder::new()
.prefix(".mise-shims-stage-")
.tempdir_in(live.path())
.unwrap();
let collision = live.path().join("collision");
fs::write(&collision, "unmanaged old file").unwrap();
fs::write(staging.path().join("collision"), "replacement shim").unwrap();
publish_staged_shim_farm(
live.path(),
&mise_bin,
staging,
HashSet::new(),
HashSet::new(),
HashSet::new(),
true,
)
.unwrap();
assert!(!orphan.exists());
assert_eq!(fs::read_to_string(collision).unwrap(), "replacement shim");
}
#[test]
fn staged_shim_publication_updates_metadata_in_shared_directory() {
let live = tempfile::tempdir().unwrap();
let mise_bin = live.path().join("mise");
fs::write(&mise_bin, "mise").unwrap();
fs::write(live.path().join(".version"), "old").unwrap();
let staging = tempfile::Builder::new()
.prefix(".mise-shims-stage-")
.tempdir_in(live.path())
.unwrap();
fs::write(staging.path().join(".version"), "new").unwrap();
publish_staged_shim_farm(
live.path(),
&mise_bin,
staging,
HashSet::new(),
HashSet::new(),
HashSet::new(),
false,
)
.unwrap();
assert_eq!(
fs::read_to_string(live.path().join(".version")).unwrap(),
"new"
);
}
#[test]
fn shared_collision_is_not_missing_or_extra() {
let actual = ActualShims {
current: HashSet::new(),
dedicated_present: HashSet::new(),
owned: HashSet::new(),
occupied: HashSet::from(["black".to_string()]),
repairable: HashSet::new(),
};
let desired = HashSet::from(["black".to_string()]);
let (missing, extra) = calculate_shim_diffs(&actual, &desired, false);
assert!(missing.is_empty());
assert!(extra.is_empty());
}
#[test]
fn dedicated_diff_reports_the_same_entries_incremental_pruning_removes() {
let actual = ActualShims {
current: HashSet::from(["node".to_string()]),
dedicated_present: HashSet::from(["node".to_string(), "orphan".to_string()]),
owned: HashSet::from(["node".to_string()]),
occupied: HashSet::from([
"node".to_string(),
"orphan".to_string(),
"foreign-symlink".to_string(),
]),
repairable: HashSet::new(),
};
let desired = HashSet::from(["node".to_string()]);
let (missing, extra) = calculate_shim_diffs(&actual, &desired, true);
assert!(missing.is_empty());
assert_eq!(extra, BTreeSet::from(["orphan".to_string()]));
}
#[test]
fn dangling_desired_mise_symlink_remains_missing_in_shared_directory() {
let actual = ActualShims {
current: HashSet::new(),
dedicated_present: HashSet::new(),
owned: HashSet::new(),
occupied: HashSet::from(["node".to_string()]),
repairable: HashSet::from(["node".to_string()]),
};
let desired = HashSet::from(["node".to_string()]);
let (missing, extra) = calculate_shim_diffs(&actual, &desired, false);
assert_eq!(missing, BTreeSet::from(["node".to_string()]));
assert!(extra.is_empty());
}
#[test]
fn stale_owned_shim_is_missing_in_shared_directory() {
let actual = ActualShims {
current: HashSet::new(),
dedicated_present: HashSet::new(),
owned: HashSet::from(["node".to_string()]),
occupied: HashSet::from(["node".to_string()]),
repairable: HashSet::new(),
};
let desired = HashSet::from(["node".to_string()]);
let (missing, extra) = calculate_shim_diffs(&actual, &desired, false);
assert_eq!(missing, BTreeSet::from(["node".to_string()]));
assert!(extra.is_empty());
}
#[cfg(unix)]
#[test]
fn symlinked_dedicated_farm_is_treated_as_shared() {
let root = tempfile::tempdir().unwrap();
let shared = root.path().join("shared");
let dedicated = root.path().join("shims");
fs::create_dir(&shared).unwrap();
std::os::unix::fs::symlink(&shared, &dedicated).unwrap();
assert!(!matches_unredirected_dedicated_dir(&dedicated, &dedicated));
assert!(matches_unredirected_dedicated_dir(&shared, &shared));
}
#[test]
fn staged_shim_publication_preserves_unstaged_desired_shims() {
let live = tempfile::tempdir().unwrap();
let mise_bin = live.path().join("mise");
fs::write(&mise_bin, "mise").unwrap();
let existing = live.path().join("existing");
fs::write(
&existing,
"#!/bin/sh\n# mise generated shim\nmise x -- existing \"$@\"\n",
)
.unwrap();
file::make_executable(&existing).unwrap();
let staging = tempfile::Builder::new()
.prefix(".mise-shims-stage-")
.tempdir_in(live.path())
.unwrap();
publish_staged_shim_farm(
live.path(),
&mise_bin,
staging,
HashSet::from(["existing".to_string()]),
HashSet::from(["existing".to_string()]),
HashSet::new(),
false,
)
.unwrap();
assert!(existing.exists());
}
#[cfg(unix)]
#[test]
fn mise_shim_detection_distinguishes_symlink_targets() {
let dir = tempfile::tempdir().unwrap();
let mise_bin = dir.path().join("mise");
let other_bin = dir.path().join("other");
fs::write(&mise_bin, "mise").unwrap();
fs::write(&other_bin, "other").unwrap();
let shim = dir.path().join("shim");
std::os::unix::fs::symlink(&mise_bin, &shim).unwrap();
assert!(is_mise_shim(&shim, &mise_bin).unwrap());
fs::remove_file(&shim).unwrap();
std::os::unix::fs::symlink(&other_bin, &shim).unwrap();
assert!(!is_mise_shim(&shim, &mise_bin).unwrap());
fs::remove_file(&shim).unwrap();
std::os::unix::fs::symlink(dir.path().join("old/bin/mise"), &shim).unwrap();
assert!(!is_mise_shim(&shim, &mise_bin).unwrap());
assert!(symlink_target_names_mise(&shim).unwrap());
assert!(!is_current_owned_mise_shim(&shim, &mise_bin).unwrap());
fs::remove_file(&shim).unwrap();
std::os::unix::fs::symlink(dir.path().join("scripts/mise-wrapper.sh"), &shim).unwrap();
assert!(!is_mise_shim(&shim, &mise_bin).unwrap());
assert!(!symlink_target_names_mise(&shim).unwrap());
let dispatcher = dir.path().join("mise");
fs::remove_file(&dispatcher).unwrap();
std::os::unix::fs::symlink(&other_bin, &dispatcher).unwrap();
assert!(!is_mise_shim(&dispatcher, &mise_bin).unwrap());
}
#[cfg(unix)]
#[tokio::test]
async fn current_shim_check_migrates_to_stable_launcher_path() {
let dir = tempfile::tempdir().unwrap();
let versioned = dir.path().join("Cellar/mise/1/bin/mise");
fs::create_dir_all(versioned.parent().unwrap()).unwrap();
fs::write(&versioned, "mise").unwrap();
let launcher = dir.path().join("bin/mise");
fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&versioned, &launcher).unwrap();
let shim = dir.path().join("shims/node");
fs::create_dir_all(shim.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&versioned, &shim).unwrap();
assert!(is_mise_shim(&shim, &launcher).unwrap());
assert!(!is_current_owned_mise_shim(&shim, &launcher).unwrap());
let actual = get_actual_shims(&launcher, shim.parent().unwrap())
.await
.unwrap();
let desired = HashSet::from(["node".to_string()]);
let (missing, extra) = calculate_shim_diffs(&actual, &desired, false);
assert_eq!(missing, BTreeSet::from(["node".to_string()]));
assert!(extra.is_empty());
}
#[test]
fn mise_shim_detection_recognizes_current_and_legacy_plugin_scripts() {
let dir = tempfile::tempdir().unwrap();
let mise_bin = dir.path().join("mise");
fs::write(&mise_bin, "mise").unwrap();
let shim = dir.path().join("shim");
fs::write(
&shim,
format!("{GENERATED_SHELL_SHIM_HEADER}mise x -- foo \"$@\"\n"),
)
.unwrap();
assert!(is_mise_shim(&shim, &mise_bin).unwrap());
fs::write(
&shim,
"#!/bin/sh\nexport ASDF_DATA_DIR=/tmp/mise\necho user-wrapper\nmise x -- foo \"$@\"\n",
)
.unwrap();
assert!(!is_mise_shim(&shim, &mise_bin).unwrap());
fs::write(
&shim,
"#!/bin/sh\nexport ASDF_DATA_DIR=/tmp/mise\nexport PATH=\"x:$PATH\"\nmise x -- foo \"$@\"\n",
)
.unwrap();
assert!(is_mise_shim(&shim, &mise_bin).unwrap());
fs::write(&shim, "#!/bin/sh\necho user-script\n").unwrap();
assert!(!is_mise_shim(&shim, &mise_bin).unwrap());
}
#[test]
fn native_shim_fingerprint_recognizes_current_and_transition_binaries() {
assert!(has_mise_native_shim_fingerprint(
b"PE\0mise generated native shim v1\n\0"
));
assert!(has_mise_native_shim_fingerprint(
b"mise-shim: failed to determine executable path\0mise-shim: failed to execute mise"
));
assert!(has_mise_native_shim_fingerprint(
b"__MISE_SHIM_PATH\0recursive shim invocation detected\0mise x --"
));
assert!(!has_mise_native_shim_fingerprint(
b"an unrelated executable mentioning mise x --"
));
}
#[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"
));
}
fn try_symlink_file(target: &Path, link: &Path) -> bool {
let result = {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(target, link)
}
};
match result {
Ok(()) => true,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Unsupported
) =>
{
false
}
Err(err) => panic!("failed to create file symlink: {err}"),
}
}
fn single_link_layout(temp: &Path) -> Option<(PathBuf, PathBuf)> {
let real_dir = temp.join("real").join("bin");
let links_dir = temp.join("links");
fs::create_dir_all(&real_dir).unwrap();
fs::create_dir_all(&links_dir).unwrap();
let real_mise = real_dir.join("mise.exe");
fs::write(&real_mise, "mise").unwrap();
let real_shim = real_dir.join("mise-shim.exe");
fs::write(&real_shim, "mise-shim").unwrap();
let linked_mise = links_dir.join("mise.exe");
if !try_symlink_file(&real_mise, &linked_mise) {
return None;
}
Some((linked_mise, real_shim))
}
#[test]
fn find_mise_shim_bin_finds_the_shim_beside_the_binary() {
let temp = tempfile::tempdir().unwrap();
let bin = temp.path().join("bin");
fs::create_dir_all(&bin).unwrap();
fs::write(bin.join("mise.exe"), "mise").unwrap();
let shim = bin.join("mise-shim.exe");
fs::write(&shim, "mise-shim").unwrap();
let found = find_mise_shim_bin(&bin.join("mise.exe")).expect("shim beside the binary");
assert_eq!(
dunce::canonicalize(&found).unwrap(),
dunce::canonicalize(&shim).unwrap()
);
}
#[test]
fn find_mise_shim_bin_follows_a_symlinked_mise_bin() {
let temp = tempfile::tempdir().unwrap();
let Some((linked_mise, real_shim)) = single_link_layout(temp.path()) else {
return;
};
let found = find_mise_shim_bin(&linked_mise).expect("shim beside the real binary");
assert_eq!(
dunce::canonicalize(&found).unwrap(),
dunce::canonicalize(&real_shim).unwrap()
);
}
#[test]
fn find_mise_shim_bin_follows_chained_symlinks() {
let temp = tempfile::tempdir().unwrap();
let Some((linked_mise, real_shim)) = single_link_layout(temp.path()) else {
return;
};
let redirect_dir = temp.path().join("redirect");
fs::create_dir_all(&redirect_dir).unwrap();
let redirected = redirect_dir.join("mise.exe");
if !try_symlink_file(&linked_mise, &redirected) {
return;
}
let found = find_mise_shim_bin(&redirected).expect("shim through two links");
assert_eq!(
dunce::canonicalize(&found).unwrap(),
dunce::canonicalize(&real_shim).unwrap()
);
}
#[test]
fn find_mise_shim_bin_prefers_the_shim_beside_the_real_binary() {
let temp = tempfile::tempdir().unwrap();
let Some((linked_mise, real_shim)) = single_link_layout(temp.path()) else {
return;
};
let beside_link = temp.path().join("links").join("mise-shim.exe");
fs::write(&beside_link, "another shim").unwrap();
let found = find_mise_shim_bin(&linked_mise).expect("a shim beside the real binary");
assert_eq!(
dunce::canonicalize(&found).unwrap(),
dunce::canonicalize(&real_shim).unwrap()
);
assert_ne!(
dunce::canonicalize(&found).unwrap(),
dunce::canonicalize(&beside_link).unwrap()
);
}
#[test]
fn find_mise_shim_bin_returns_none_when_no_shim_exists() {
let temp = tempfile::tempdir().unwrap();
let bin = temp.path().join("bin");
fs::create_dir_all(&bin).unwrap();
let mise = bin.join("mise.exe");
fs::write(&mise, "mise").unwrap();
let found = find_mise_shim_bin(&mise);
if file::which("mise-shim.exe")
.filter(|p| p.is_file())
.is_none()
{
assert_eq!(found, None);
}
}
}