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::exec::Exec;
use crate::config::{Config, Settings};
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 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(" "));
args[0] = which_shim(&mut config, &env::MISE_BIN_NAME, &args)
.await?
.to_string_lossy()
.to_string();
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");
exec.run().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> {
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?;
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);
}
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);
}
}
}
let mise_bin = file::canonicalize_or_self(&env::MISE_BIN);
for path in &*env::PATH {
if file::is_mise_shims_dir(path) {
continue;
}
let bin = path.join(bin_name);
if bin.exists() {
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);
}
}
let tvs = ts.list_rtvs_with_bin(config, bin_name).await?;
err_no_version_set(config, ts, bin_name, tvs).await
}
#[cfg(not(test))]
pub 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 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 = file::which_no_shims("mise").unwrap_or(env::MISE_BIN.clone());
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<_>>>()?;
Ok(())
}
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 = path.with_extension("old");
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))),
}
}
#[cfg(windows)]
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!(
"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(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"),
formatdoc! {r#"
@echo off
setlocal
set "shim_path=%~f0"
if /I "%__MISE_SHIM_PATH%"=="%shim_path%" (
echo mise: recursive shim invocation detected for {shim}: %shim_path% 1>&2
exit /b 1
)
set "__MISE_SHIM_PATH=%shim_path%"
mise x -- {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 struct ShimDiffs {
pub missing: BTreeSet<String>,
pub extra: BTreeSet<String>,
pub desired: HashSet<String>,
}
pub 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>> {
Ok(dirs::SHIMS
.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()
});
if cfg!(windows) {
#[cfg(windows)]
let shim_mode = effective_shim_mode(_mise_bin);
#[cfg(not(windows))]
let shim_mode = String::new();
shims.extend(bins.into_iter().flat_map(|b| {
let p = PathBuf::from(&b);
match shim_mode.as_ref() {
"hardlink" | "symlink" => {
vec![p.with_extension("exe").to_string_lossy().to_string()]
}
"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) {
shims.extend(bins.into_iter().map(|b| b.to_lowercase()));
} else {
shims.extend(bins);
}
}
Ok(shims)
}
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())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::args::BackendArg;
use crate::toolset::{ToolRequest, ToolSource, ToolVersionList};
#[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"));
}
#[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"
));
}
}