use crate::file::display_path;
use crate::http::HTTP;
use crate::ui::info;
use crate::{Result, file, minisign};
use eyre::{bail, eyre};
use std::path::{Path, PathBuf};
use xx::regex;
#[derive(Debug, usage_rs::Args)]
#[usage(
verbatim_doc_comment,
example(
r###"mise generate install-script --write ./bin/mise
./bin/mise install"###,
help = "Download mise to .mise if it is not already installed."
),
example(
r###"mise generate install-script --write ./bin/mise --windows
.\bin\mise.cmd install"###,
help = r###"Write bin/mise.cmd as a launcher for contributors who clone the project on Windows."###
)
)]
pub(super) struct InstallScript {
#[usage(long, short, verbatim_doc_comment)]
localize: bool,
#[usage(long, short = 'V', verbatim_doc_comment)]
version: Option<String>,
#[usage(long, short, verbatim_doc_comment, num_args=0..=1, default_missing = "./bin/mise")]
write: Option<PathBuf>,
#[usage(long, verbatim_doc_comment, default=".mise", value_hint=ValueHint::DirPath)]
localized_dir: PathBuf,
#[usage(long, verbatim_doc_comment, requires = "write")]
windows: bool,
}
fn non_localized_bash_vars(version: &str) -> String {
format!(
r#"
local mise_data_dir="${{MISE_DATA_DIR:-${{XDG_DATA_HOME:-$HOME/.local/share}}/mise}}"
# mise itself expands a leading ~ in these (env::var_path -> file::replace_path); parameter
# expansion does not, so without this a tilde value ends up as a relative directory. CI YAML
# supplies exactly this shape (MISE_DATA_DIR: ~/.local/share/mise).
case $mise_data_dir in
"~") mise_data_dir=$HOME ;;
"~/"*) mise_data_dir=$HOME/${{mise_data_dir#\~/}} ;;
esac
local cache_home="${{XDG_CACHE_HOME:-$HOME/.cache}}/mise"
local mise_version="${{MISE_VERSION:-{version}}}"
mise_version="${{mise_version#v}}"
# Only a defaulted path may fall back: an explicit MISE_INSTALL_PATH wins even while its file
# does not exist yet.
if [ -z "${{MISE_INSTALL_PATH:-}}" ]; then
export MISE_INSTALL_PATH="$mise_data_dir/bootstrap/mise-$mise_version"
# Wrappers generated before the data-dir default put the binary in the cache dir; run that
# one instead of re-downloading it. -x so a non-executable leftover reinstalls rather than
# dying at exec.
if [ ! -f "$MISE_INSTALL_PATH" ] && [ -x "$cache_home/mise-$mise_version" ]; then
MISE_INSTALL_PATH="$cache_home/mise-$mise_version"
fi
fi
"#
)
}
impl InstallScript {
pub(super) async fn run(self) -> eyre::Result<()> {
let (output, version) = self.generate().await?;
if let Some(bin) = &self.write {
if let Some(parent) = bin.parent() {
file::create_dir_all(parent)?;
}
file::write(bin, &output)?;
file::make_executable(bin)?;
miseprintln!("Wrote to {}", display_path(bin));
if self.windows {
match windows_script_path(bin) {
Some(cmd) => {
let body = self.generate_windows(&version).await?;
file::write(&cmd, &body)?;
miseprintln!("Wrote to {}", display_path(&cmd));
}
None => warn!(
"--windows wrote nothing: {} already ends in an extension Windows runs. \
Point --write at a name without one.",
display_path(bin)
),
}
}
} else {
miseprintln!("{output}");
}
Ok(())
}
async fn generate(&self) -> Result<(String, String)> {
let url = if let Some(v) = &self.version {
format!("https://mise.jdx.dev/v{v}/install.sh")
} else {
"https://mise.jdx.dev/install.sh".into()
};
let install = HTTP.get_text(&url).await?;
let install_sig = HTTP.get_text(format!("{url}.minisig")).await?;
minisign::verify(&minisign::MISE_PUB_KEY, install.as_bytes(), &install_sig)?;
let install = info::indent_by(install, " ");
let version = regex!(r#"version="\$\{MISE_VERSION:-v([0-9.]+)\}""#)
.captures(&install)
.unwrap()
.get(1)
.unwrap()
.as_str();
let vars = if self.localize {
let localized_dir = self.localized_dir.to_string_lossy();
let localized_dir = shell_words::quote(&localized_dir);
let localized_dir = if self.localized_dir.is_absolute() {
localized_dir.into_owned()
} else {
format!(r#""$project_dir"/{localized_dir}"#)
};
format!(
r#"
local project_dir=$( cd -- "$( dirname -- "${{BASH_SOURCE[0]}}" )" &> /dev/null && cd .. && pwd )
local localized_dir={localized_dir}
export MISE_DATA_DIR="$localized_dir"
export MISE_CONFIG_DIR="$localized_dir"
export MISE_CACHE_DIR="$localized_dir/cache"
export MISE_STATE_DIR="$localized_dir/state"
local mise_version="${{MISE_VERSION:-{version}}}"
mise_version="${{mise_version#v}}"
export MISE_INSTALL_PATH="${{MISE_INSTALL_PATH:-$localized_dir/mise-$mise_version}}"
export MISE_TRUSTED_CONFIG_PATHS="$project_dir${{MISE_TRUSTED_CONFIG_PATHS:+:$MISE_TRUSTED_CONFIG_PATHS}}"
export MISE_IGNORED_CONFIG_PATHS="$HOME/.config/mise${{MISE_IGNORED_CONFIG_PATHS:+:$MISE_IGNORED_CONFIG_PATHS}}"
"#
)
} else {
non_localized_bash_vars(version)
};
let vars = info::indent_by(vars.trim(), " ");
let script = format!(
r#"
#!/usr/bin/env bash
set -eu
__mise_bootstrap() {{
{vars}
install() {{
local initial_working_dir="$PWD"
{install}
cd -- "$initial_working_dir"
}}
local MISE_INSTALL_HELP=0
test -f "$MISE_INSTALL_PATH" || install
}}
__mise_bootstrap
exec -a "$0" "$MISE_INSTALL_PATH" "$@"
"#
);
Ok((script.trim().to_string(), version.to_string()))
}
async fn generate_windows(&self, version: &str) -> Result<String> {
let base = format!("https://github.com/jdx/mise/releases/download/v{version}");
let sums = HTTP.get_text(format!("{base}/SHASUMS256.txt")).await?;
let sums_sig = HTTP
.get_text(format!("{base}/SHASUMS256.txt.minisig"))
.await?;
minisign::verify(&minisign::MISE_PUB_KEY, sums.as_bytes(), &sums_sig)?;
let checksum = |arch| {
windows_checksum(&sums, version, arch).ok_or_else(|| {
eyre!("SHASUMS256.txt for v{version} has no entry for windows-{arch}")
})
};
let vars = if self.localize {
let localized_dir = windows_localized_dir(&self.localized_dir)?;
format!(
r#"
set "project_dir=%~dp0.."
set "localized_dir={localized_dir}"
set "MISE_DATA_DIR=%localized_dir%"
set "MISE_CONFIG_DIR=%localized_dir%"
set "MISE_CACHE_DIR=%localized_dir%\cache"
set "MISE_STATE_DIR=%localized_dir%\state"
set "install_dir=%localized_dir%"
set "MISE_TRUSTED_CONFIG_PATHS=%project_dir%;%MISE_TRUSTED_CONFIG_PATHS%"
rem The global config lives under XDG_CONFIG_HOME, which mise resolves the same way on every
rem platform -- `XDG_CONFIG_HOME` or `%USERPROFILE%\.config`, *not* `%LOCALAPPDATA%`. That is
rem the data dir's rule, and using it here would leave the user's real global config loaded,
rem which is the one thing --localize exists to prevent.
set "xdg_config=%XDG_CONFIG_HOME%"
if not defined XDG_CONFIG_HOME set "xdg_config=%USERPROFILE%\.config"
set "MISE_IGNORED_CONFIG_PATHS=%xdg_config%\mise;%MISE_IGNORED_CONFIG_PATHS%""#
)
} else {
r#"
set "install_dir=%LOCALAPPDATA%\mise""#
.to_string()
};
Ok(windows_script(
version,
checksum("x64")?,
checksum("arm64")?,
vars.trim_end(),
self.localize,
))
}
}
const WINDOWS_FORBIDDEN_IN_COMPONENT: [char; 7] = ['<', '>', ':', '"', '|', '?', '*'];
fn windows_path_components(dir: &str) -> &str {
let bytes = dir.as_bytes();
let rest = match bytes {
[drive, b':', b'\\' | b'/', ..] if drive.is_ascii_alphabetic() => &dir[2..],
_ => dir,
};
rest.trim_start_matches(['\\', '/'])
}
fn windows_localized_dir(dir: &Path) -> Result<String> {
let raw = dir.to_string_lossy();
if let Some(bad) = windows_path_components(&raw)
.chars()
.find(|c| WINDOWS_FORBIDDEN_IN_COMPONENT.contains(c))
{
bail!(
"--localized-dir {raw:?} cannot be carried to Windows: {bad:?} is not allowed in a \
path component there, so the generated launcher could not create the directory. \
Drop --windows, or pick a name Windows accepts."
);
}
let escaped = cmd_escape(&raw);
match is_windows_absolute(&escaped) {
true => Ok(escaped),
false => Ok(format!(r"%project_dir%\{escaped}")),
}
}
fn cmd_escape(value: &str) -> String {
value.replace('%', "%%")
}
fn is_windows_absolute(dir: &str) -> bool {
let bytes = dir.as_bytes();
if matches!(bytes.first(), Some(b'\\' | b'/')) {
return true;
}
matches!(bytes, [drive, b':', b'\\' | b'/', ..] if drive.is_ascii_alphabetic())
}
fn windows_script_path(bin: &Path) -> Option<PathBuf> {
let name = bin.file_name()?.to_str()?;
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
if matches!(ext.as_deref(), Some("cmd" | "bat" | "exe")) {
return None;
}
Some(bin.with_file_name(format!("{name}.cmd")))
}
fn windows_checksum<'a>(sums: &'a str, version: &str, arch: &str) -> Option<&'a str> {
let name = format!("mise-v{version}-windows-{arch}.exe");
sums.lines()
.find(|line| line.trim_end().ends_with(&name))
.and_then(|line| line.split_whitespace().next())
}
fn windows_script(
version: &str,
sum_x64: &str,
sum_arm64: &str,
vars: &str,
localized: bool,
) -> String {
let install_path_block = if localized {
r#"
if not defined MISE_INSTALL_PATH set "MISE_INSTALL_PATH=%install_dir%\mise-%resolved_version%.exe"
if exist "%MISE_INSTALL_PATH%" goto :run"#
} else {
r#"
if not defined MISE_INSTALL_PATH (
set "MISE_INSTALL_PATH=%install_dir%\bootstrap\mise-%resolved_version%.exe"
rem Launchers generated before the bootstrap\ subdirectory put the binary directly under
rem %install_dir%; run that one rather than re-downloading it. Only the defaulted path falls
rem back -- an explicit MISE_INSTALL_PATH wins even while its file does not exist yet.
if not exist "%install_dir%\bootstrap\mise-%resolved_version%.exe" if exist "%install_dir%\mise-%resolved_version%.exe" (
set "MISE_INSTALL_PATH=%install_dir%\mise-%resolved_version%.exe"
)
)
if exist "%MISE_INSTALL_PATH%" goto :run"#
};
format!(
r#"@echo off
rem Delayed expansion stays OFF for the whole script. With it on, cmd runs a second expansion pass
rem over every already-substituted line, so a `!` anywhere in a path -- the project directory this
rem sits in, MISE_INSTALL_PATH, TEMP -- is silently eaten and the script reads and writes a
rem different path than the one it was given. Measured, with delayed expansion off:
rem set "d=%TEMP%\p6-od!d" & mkdir "%d%" -> created ...\p6-od!d
rem and with it on the same two lines create `...\p6-odd` instead. That also covers `%*` on the
rem :run line, which would otherwise be re-expanded and corrupt an argument containing `!`.
rem
rem The one thing this costs is reading a variable assigned inside a parenthesised block, which is
rem why the checksum fallback below is written with a label rather than `if not defined (...)`.
setlocal DisableDelayedExpansion
rem generated by mise generate install-script
{vars}
rem Cleared before use, not merely assigned later: the failure paths below delete whatever these
rem name, and cmd inherits the caller's environment. A stray value here would make this script
rem delete something it never created.
set "download_path="
set "sums="
set "pinned_version={version}"
set "sum_x64={sum_x64}"
set "sum_arm64={sum_arm64}"
rem MISE_VERSION itself is never written to. Everything here runs inside `setlocal`, so assigning
rem a fallback to it would hand the launched mise an env var the bash branch does not set.
rem
rem The name has to differ from MISE_VERSION by more than case: cmd variable names are
rem case-insensitive, so a local called `mise_version` *is* MISE_VERSION and would overwrite the
rem caller's value before this line could read it.
set "resolved_version=%pinned_version%"
if defined MISE_VERSION set "resolved_version=%MISE_VERSION%"
if "%resolved_version:~0,1%"=="v" set "resolved_version=%resolved_version:~1%"
set "arch=x64"
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "arch=arm64"
if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" set "arch=arm64"
{install_path_block}
set "release=https://github.com/jdx/mise/releases/download/v%resolved_version%"
if "%arch%"=="arm64" (set "expected=%sum_arm64%") else (set "expected=%sum_x64%")
if not "%resolved_version%"=="%pinned_version%" set "expected="
rem A label rather than `if not defined expected (...)`: `sums` is assigned and then read in the
rem same block, which is the one thing plain expansion cannot do. Delayed expansion is the usual
rem answer and is exactly what this script must not turn on -- see the top.
if defined expected goto :have_checksum
set "sums=%TEMP%\mise-shasums-%RANDOM%%RANDOM%.txt"
curl -fsSL -o "%sums%" "%release%/SHASUMS256.txt" || goto :fail_download
for /f "tokens=1" %%s in ('findstr /c:"mise-v%resolved_version%-windows-%arch%.exe" "%sums%"') do set "expected=%%s"
del "%sums%" 2>nul
:have_checksum
if not defined expected (
echo mise bootstrap: no checksum published for windows-%arch% at v%resolved_version% 1>&2
exit /b 1
)
set "url=%release%/mise-v%resolved_version%-windows-%arch%.exe"
set "download_path=%TEMP%\mise-bootstrap-%RANDOM%%RANDOM%.exe"
curl -fsSL -o "%download_path%" "%url%" || goto :fail_download
set "actual="
for /f "skip=1 delims=" %%h in ('certutil -hashfile "%download_path%" SHA256') do if not defined actual set "actual=%%h"
set "actual=%actual: =%"
rem `actual` is set above rather than inside this block, so plain expansion reads it correctly:
rem the whole `if (...)` is parsed when it is reached, which is after the `for` loop ran.
if /i not "%actual%"=="%expected%" (
echo mise bootstrap: checksum mismatch for %url% 1>&2
echo expected %expected% 1>&2
echo actual %actual% 1>&2
del "%download_path%" 2>nul
exit /b 1
)
rem The parent of MISE_INSTALL_PATH, not install_dir: a caller-supplied MISE_INSTALL_PATH can
rem name a file anywhere, and creating install_dir instead would leave the move below with no
rem destination directory. install.sh does the same -- `mkdir -p "$(dirname "$install_path")"` --
rem and the default path lives under install_dir, so this covers that case too.
for %%i in ("%MISE_INSTALL_PATH%") do set "install_parent=%%~dpi"
if not exist "%install_parent%" mkdir "%install_parent%"
rem Checked, because falling through to :run with the move having failed would execute a path
rem that does not exist and report cmd's error instead of this one.
move /y "%download_path%" "%MISE_INSTALL_PATH%" >nul || goto :fail_install
:run
"%MISE_INSTALL_PATH%" %*
exit /b %ERRORLEVEL%
:fail_download
rem A 404 leaves nothing behind on current curl -- measured on 8.21.0 -- but an interrupted
rem transfer does, and this download is over 100MB, so the partial file is worth removing.
rem
rem The name matters: `tmp` here would be `%TMP%`, because cmd names are case-insensitive, and
rem `del` on a directory prompts to erase its contents. Measured, before this was renamed:
rem C:\Users\<user>\AppData\Local\Temp\*, Are you sure (Y/N)?
if defined sums del "%sums%" 2>nul
if defined download_path del "%download_path%" 2>nul
echo mise bootstrap: failed to download from %release% 1>&2
exit /b 1
:fail_install
del "%download_path%" 2>nul
echo mise bootstrap: could not move the downloaded binary to %MISE_INSTALL_PATH% 1>&2
exit /b 1
"#
)
}
#[cfg(test)]
mod windows_bootstrap_tests {
use super::*;
const SUMS: &str = "\
64d7073fe0fe336d49582755acf89991f93d0a182a9d1279fb949080d66e9849 ./mise-v2026.8.4-windows-arm64.exe
0c1f3f0553459e366b284db2a2bf2f0b2c52c6838dddccee96fb494309c97126 ./mise-v2026.8.4-windows-arm64.zip
6f18aaa1a1dc60f929d872380117ddc86f842df03816197fb601b9e3c8254228 ./mise-v2026.8.4-windows-x64.exe
9ce8169533c129c05b8d0daf0325eb5d72fcaa1908cd49adff35a82e3f4f69e1 ./mise-v2026.8.4-windows-x64.zip
b6760c6c4d5e629c31e31cb8a5018316338b01592408062a2aed673cec63cb2d ./mise-v2026.8.4-linux-x64.tar.gz
";
#[test]
fn checksum_picks_the_exe_for_the_right_arch() {
assert_eq!(
windows_checksum(SUMS, "2026.8.4", "x64"),
Some("6f18aaa1a1dc60f929d872380117ddc86f842df03816197fb601b9e3c8254228")
);
assert_eq!(
windows_checksum(SUMS, "2026.8.4", "arm64"),
Some("64d7073fe0fe336d49582755acf89991f93d0a182a9d1279fb949080d66e9849")
);
}
#[test]
fn checksum_is_absent_rather_than_wrong() {
assert_eq!(windows_checksum(SUMS, "2026.8.3", "x64"), None);
assert_eq!(windows_checksum(SUMS, "2026.8.4", "riscv64"), None);
}
#[test]
fn script_carries_both_checksums_and_the_marker() {
let script = windows_script(
"2026.8.4",
windows_checksum(SUMS, "2026.8.4", "x64").unwrap(),
windows_checksum(SUMS, "2026.8.4", "arm64").unwrap(),
"\nset \"install_dir=%LOCALAPPDATA%\\mise\"",
false,
);
assert!(
script.contains("6f18aaa1a1dc60f929d872380117ddc86f842df03816197fb601b9e3c8254228")
);
assert!(
script.contains("64d7073fe0fe336d49582755acf89991f93d0a182a9d1279fb949080d66e9849")
);
assert!(script.contains("rem generated by mise generate install-script"));
assert!(
script.contains(r#"if not "%resolved_version%"=="%pinned_version%" set "expected=""#)
);
assert!(!script.contains(r#"set "mise_version="#));
}
#[test]
fn the_cmd_sits_beside_the_script() {
assert_eq!(
windows_script_path(Path::new("bin/mise")),
Some(PathBuf::from("bin/mise.cmd"))
);
}
#[test]
fn a_name_that_windows_can_already_run_gets_none() {
for name in ["mise.cmd", "mise.BAT", "mise.exe"] {
assert_eq!(windows_script_path(Path::new(name)), None, "{name}");
}
}
#[test]
fn a_relative_localized_dir_is_joined_to_the_project() {
assert_eq!(
windows_localized_dir(Path::new(".mise")).unwrap(),
r"%project_dir%\.mise"
);
}
#[test]
fn a_localized_dir_windows_cannot_hold_is_refused() {
for dir in ["C:foo", "a:b", "tools/x|y", "we?ird", r"C:\tools\a:b"] {
let err = windows_localized_dir(Path::new(dir))
.unwrap_err()
.to_string();
assert!(err.contains("cannot be carried to Windows"), "{dir}: {err}");
}
}
#[test]
fn a_drive_colon_is_not_treated_as_a_forbidden_character() {
for dir in [
r"C:\tools\mise",
"C:/tools/mise",
r"\\server\share",
"/rooted",
] {
assert!(windows_localized_dir(Path::new(dir)).is_ok(), "{dir}");
}
}
#[test]
fn an_absolute_localized_dir_is_left_alone() {
for dir in [
r"C:\tools\mise",
"C:/tools/mise",
r"\\server\share",
"/rooted",
] {
assert_eq!(
windows_localized_dir(Path::new(dir)).unwrap(),
cmd_escape(dir),
"{dir}"
);
}
}
#[test]
fn drive_relative_and_plain_names_are_not_absolute() {
for dir in ["C:foo", ".mise", "tools/mise", "C-drive"] {
assert!(!is_windows_absolute(dir), "{dir}");
}
}
#[test]
fn a_percent_in_the_directory_is_escaped() {
assert_eq!(
windows_localized_dir(Path::new("my%x%dir")).unwrap(),
r"%project_dir%\my%%x%%dir"
);
assert_eq!(
windows_localized_dir(Path::new("my!x!dir")).unwrap(),
r"%project_dir%\my!x!dir"
);
}
#[test]
fn the_launcher_creates_the_parent_of_the_install_path() {
let script = windows_script(
"2026.8.4",
windows_checksum(SUMS, "2026.8.4", "x64").unwrap(),
windows_checksum(SUMS, "2026.8.4", "arm64").unwrap(),
"\nset \"install_dir=%LOCALAPPDATA%\\mise\"",
false,
);
assert!(
script.contains(r#"for %%i in ("%MISE_INSTALL_PATH%") do set "install_parent=%%~dpi""#)
);
assert!(script.contains(r#"if not exist "%install_parent%" mkdir "%install_parent%""#));
assert!(!script.contains(r#"if not exist "%install_dir%" mkdir "%install_dir%""#));
}
#[test]
fn the_launcher_never_enables_delayed_expansion() {
let script = windows_script(
"2026.8.4",
windows_checksum(SUMS, "2026.8.4", "x64").unwrap(),
windows_checksum(SUMS, "2026.8.4", "arm64").unwrap(),
"\nset \"install_dir=%LOCALAPPDATA%\\mise\"",
false,
);
assert!(!script.contains("EnableDelayedExpansion"));
assert!(script.contains("setlocal DisableDelayedExpansion"));
assert!(
!regex!(r"![A-Za-z_][A-Za-z0-9_]*!").is_match(&script),
"script still contains a delayed-expansion read"
);
}
#[test]
fn the_default_install_path_lives_under_bootstrap_with_a_legacy_fallback() {
let script = windows_script(
"2026.8.4",
windows_checksum(SUMS, "2026.8.4", "x64").unwrap(),
windows_checksum(SUMS, "2026.8.4", "arm64").unwrap(),
"\nset \"install_dir=%LOCALAPPDATA%\\mise\"",
false,
);
assert!(
script.contains(
r#"set "MISE_INSTALL_PATH=%install_dir%\bootstrap\mise-%resolved_version%.exe""#
),
"windows launcher must default MISE_INSTALL_PATH under bootstrap\\"
);
assert!(
script.contains(
r#"if not exist "%install_dir%\bootstrap\mise-%resolved_version%.exe" if exist "%install_dir%\mise-%resolved_version%.exe""#
),
"windows launcher must fall back to the pre-bootstrap install location"
);
assert!(
!script.contains(r#"%MISE_INSTALL_PATH%"=="%install_dir%"#),
"fallback must not reconstruct whether the path was defaulted"
);
}
#[test]
fn the_localized_launcher_keeps_its_binary_beside_the_script() {
let script = windows_script(
"2026.8.4",
windows_checksum(SUMS, "2026.8.4", "x64").unwrap(),
windows_checksum(SUMS, "2026.8.4", "arm64").unwrap(),
"\nset \"install_dir=%project_dir%\\.mise\"",
true,
);
assert!(script.contains(
r#"if not defined MISE_INSTALL_PATH set "MISE_INSTALL_PATH=%install_dir%\mise-%resolved_version%.exe""#
));
assert!(!script.contains(r#"%install_dir%\bootstrap\"#));
}
#[test]
fn the_bash_wrapper_defaults_to_the_data_dir_not_the_cache() {
let vars = non_localized_bash_vars("2026.8.10");
assert!(
vars.contains(
r#"export MISE_INSTALL_PATH="$mise_data_dir/bootstrap/mise-$mise_version""#
),
"bash wrapper must default MISE_INSTALL_PATH under the data dir"
);
assert!(vars.contains(r#"if [ -z "${MISE_INSTALL_PATH:-}" ]; then"#));
assert!(
!vars.contains(r#"MISE_INSTALL_PATH="${MISE_INSTALL_PATH:-$cache_home"#),
"default install path must not point into the cache dir"
);
}
#[test]
fn the_bash_wrapper_expands_a_tilde_prefixed_data_dir() {
let vars = non_localized_bash_vars("2026.8.10");
assert!(vars.contains(r#" "~/"*) mise_data_dir=$HOME/${mise_data_dir#\~/} ;;"#));
}
#[test]
fn the_bash_wrapper_reuses_a_pre_data_dir_binary_from_the_cache() {
let vars = non_localized_bash_vars("2026.8.10");
assert!(vars.contains(r#"[ -x "$cache_home/mise-$mise_version" ]"#));
assert!(vars.contains(r#"MISE_INSTALL_PATH="$cache_home/mise-$mise_version""#));
}
}