use std::path::{Path, PathBuf};
use std::process::Command;
use std::{collections::BTreeMap, collections::BTreeSet, ffi::OsString, sync::Arc};
use crate::backend::VersionInfo;
use crate::backend::options::BackendOptions;
use crate::backend::{Backend, IdiomaticVersion, platform_target::PlatformTarget};
use crate::build_time::TARGET;
use crate::cli::args::BackendArg;
use crate::cmd::{CmdLineRunner, cmd};
use crate::config::{Config, Settings};
use crate::http::{HTTP, HTTP_FETCH};
use crate::install_context::InstallContext;
use crate::lock_file::LockFile;
use crate::toolset::outdated_info::OutdatedInfo;
use crate::toolset::{ResolveOptions, ToolRequest, ToolVersion, ToolVersionOptions, Toolset};
use crate::ui::progress_report::SingleReport;
use crate::{dirs, env, file, github, plugins};
use async_trait::async_trait;
use eyre::{Context, Result};
use indexmap::IndexMap;
use xx::regex;
#[derive(Debug)]
pub(super) struct RustPlugin {
ba: Arc<BackendArg>,
}
const RUST_NIGHTLY_MANIFEST_URL: &str =
"https://static.rust-lang.org/dist/channel-rust-nightly.toml";
fn parse_nightly_manifest(manifest: &str) -> Result<String> {
let manifest: toml::Value =
toml::from_str(manifest).wrap_err("failed to parse the Rust nightly channel manifest")?;
let date = manifest
.get("date")
.and_then(toml::Value::as_str)
.ok_or_else(|| eyre::eyre!("Rust nightly channel manifest is missing its date"))?;
date.parse::<jiff::civil::Date>()
.wrap_err_with(|| format!("invalid Rust nightly channel manifest date: {date}"))?;
Ok(format!("nightly-{date}"))
}
async fn current_nightly_version() -> Result<String> {
let manifest = HTTP_FETCH
.get_text_cached(RUST_NIGHTLY_MANIFEST_URL)
.await
.wrap_err("failed to fetch the Rust nightly channel manifest")?;
parse_nightly_manifest(&manifest)
}
fn is_dated_nightly(version: &str) -> bool {
version
.strip_prefix("nightly-")
.is_some_and(|date| date.parse::<jiff::civil::Date>().is_ok())
}
fn latest_installed_nightly(versions: impl DoubleEndedIterator<Item = String>) -> Option<String> {
versions.rev().find(|version| is_dated_nightly(version))
}
#[derive(Debug, Clone, Copy)]
struct RustOptions<'a> {
values: BackendOptions<'a>,
}
impl<'a> RustOptions<'a> {
fn new(raw: &'a ToolVersionOptions) -> Self {
Self {
values: BackendOptions::new(raw),
}
}
fn profile(&self) -> Option<&'a str> {
self.values.str("profile")
}
fn comma_list(&self, name: &str) -> Option<Vec<String>> {
let normalize = |value: &str| {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
};
match self.values.raw().opts.get(name) {
Some(toml::Value::String(value)) => {
Some(value.split(',').filter_map(normalize).collect())
}
Some(toml::Value::Array(values)) => Some(
values
.iter()
.filter_map(|value| value.as_str().and_then(normalize))
.collect(),
),
_ => None,
}
}
fn install_args(&self) -> (Option<String>, Option<Vec<String>>, Option<Vec<String>>) {
let profile = self.profile().map(str::to_string);
let components = self.comma_list("components");
let targets = self.comma_list("targets");
(profile, components, targets)
}
fn lockfile_options(&self) -> BTreeMap<String, String> {
let (profile, components, targets) = self.install_args();
let mut opts = BTreeMap::new();
if let Some(profile) = profile
&& !profile.is_empty()
{
opts.insert("profile".into(), profile);
}
if let Some(components) = components
&& !components.is_empty()
{
let mut components = components;
components.sort();
components.dedup();
opts.insert("components".into(), components.join(","));
}
if let Some(targets) = targets
&& !targets.is_empty()
{
let mut targets = targets;
targets.sort();
targets.dedup();
opts.insert("targets".into(), targets.join(","));
}
opts
}
}
impl RustPlugin {
pub(super) fn new() -> Self {
Self {
ba: plugins::core::new_backend_arg("rust").into(),
}
}
async fn setup_rustup(
&self,
ctx: &InstallContext,
tv: &ToolVersion,
runtime: &RustRuntime,
) -> Result<()> {
if runtime.is_external() {
return Ok(());
}
let homes = &runtime.homes;
let settings = Settings::get();
if rustup_is_initialized(homes) {
return Ok(());
}
let _installer_lock = tokio::task::spawn_blocking(|| {
LockFile::new(&rustup_path())
.with_callback(|path| {
debug!(
"waiting for rustup-init lock on {}",
file::display_path(path)
);
})
.lock()
})
.await??;
if rustup_is_initialized(homes) {
return Ok(());
}
ctx.pr.set_message("Downloading rustup-init".into());
HTTP.download_file(rustup_url(&settings), &rustup_path(), Some(ctx.pr.as_ref()))
.await?;
file::make_executable(rustup_path())?;
file::create_dir_all(&homes.rustup)?;
let mut cmd = CmdLineRunner::new(rustup_path())
.with_pr(ctx.pr.as_ref())
.arg("--no-modify-path")
.arg("--default-toolchain")
.arg("none")
.arg("-y")
.env_values(tv.install_env())
.envs(rustup_env(homes, &tv.version));
if let Some(host) = settings.rust.default_host.as_ref() {
cmd = cmd.arg("--default-host").arg(host);
}
cmd.execute()?;
Ok(())
}
async fn test_rust(
&self,
ctx: &InstallContext,
tv: &ToolVersion,
runtime: &RustRuntime,
) -> Result<()> {
ctx.pr.set_message(format!("{RUSTC_BIN} -V"));
CmdLineRunner::new(runtime.bin_dir.join(RUSTC_BIN))
.with_pr(ctx.pr.as_ref())
.arg("-V")
.env_values(tv.install_env())
.envs(rustup_env(&runtime.homes, &tv.version))
.prepend_path(vec![runtime.bin_dir.clone()])?
.execute()
}
fn target_triple(&self, tv: &ToolVersion) -> String {
format!("{}-{}", tv.version, TARGET)
}
fn rustup_installed_items(
&self,
tv: &ToolVersion,
subcommand: &str,
runtime: &RustRuntime,
) -> Result<Option<BTreeSet<String>>> {
let args = vec![
subcommand.to_string(),
"list".to_string(),
"--installed".to_string(),
"--toolchain".to_string(),
tv.version.clone(),
];
let mut cmd = cmd(runtime.bin_dir.join(RUSTUP_BIN), args)
.env("PATH", rustup_path_env(runtime)?)
.stdout_capture()
.stderr_capture()
.unchecked();
for (key, value) in rustup_env(&runtime.homes, &tv.version) {
cmd = cmd.env(key, value);
}
let output = match cmd.run() {
Ok(output) => output,
Err(err) => {
debug!(
"rustup {subcommand} list failed for {}: {err:#}",
tv.style()
);
return Ok(None);
}
};
if !output.status.success() {
debug!(
"rustup {subcommand} list failed for {}: {}",
tv.style(),
String::from_utf8_lossy(&output.stderr).trim()
);
return Ok(None);
}
Ok(Some(
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(String::from)
.collect(),
))
}
fn missing_components(
&self,
requested: &[String],
installed: &BTreeSet<String>,
) -> Vec<String> {
requested
.iter()
.filter(|component| !rustup_component_installed(installed, component))
.cloned()
.collect()
}
fn missing_targets(&self, requested: &[String], installed: &BTreeSet<String>) -> Vec<String> {
requested
.iter()
.filter(|target| !installed.contains(*target))
.cloned()
.collect()
}
}
#[async_trait]
impl Backend for RustPlugin {
fn ba(&self) -> &Arc<BackendArg> {
&self.ba
}
fn supports_lockfile_url(&self) -> bool {
false
}
async fn is_install_satisfied(
&self,
config: &Arc<Config>,
tv: &ToolVersion,
check_symlink: bool,
) -> Result<bool> {
if !self.is_version_installed(config, tv, check_symlink) {
return Ok(false);
}
let runtime = RustRuntime::resolve_for_tool_version(config, tv).await?;
if !file::is_symlink_to(&tv.install_path(), &runtime.bin_dir) {
debug!(
"{} points outside the selected Rust proxy directory",
tv.install_path().display()
);
return Ok(false);
}
let raw_opts = tv.request.options();
let (_, components, targets) = RustOptions::new(&raw_opts).install_args();
if let Some(components) = components
&& !components.is_empty()
{
let Some(installed) = self.rustup_installed_items(tv, "component", &runtime)? else {
return Ok(false);
};
let missing = self.missing_components(&components, &installed);
if !missing.is_empty() {
debug!(
"{} missing rustup component(s): {}",
tv.style(),
missing.join(", ")
);
return Ok(false);
}
}
if let Some(targets) = targets
&& !targets.is_empty()
{
let Some(installed) = self.rustup_installed_items(tv, "target", &runtime)? else {
return Ok(false);
};
let missing = self.missing_targets(&targets, &installed);
if !missing.is_empty() {
debug!(
"{} missing rustup target(s): {}",
tv.style(),
missing.join(", ")
);
return Ok(false);
}
}
Ok(true)
}
fn resolve_lockfile_options(
&self,
request: &ToolRequest,
_target: &PlatformTarget,
) -> Result<BTreeMap<String, String>> {
let raw_opts = request.options();
Ok(RustOptions::new(&raw_opts).lockfile_options())
}
async fn _list_remote_versions(&self, _config: &Arc<Config>) -> Result<Vec<VersionInfo>> {
let mut versions: Vec<VersionInfo> = github::list_releases("rust-lang/rust")
.await?
.into_iter()
.map(|r| {
let created_at = Some(r.released_at().to_string());
VersionInfo {
release_url: Some(format!("https://releases.rs/docs/{}/", r.tag_name)),
version: r.tag_name,
created_at,
..Default::default()
}
})
.rev()
.collect();
if let Ok(current_nightly) = current_nightly_version().await {
versions.push(VersionInfo {
version: current_nightly,
..Default::default()
});
}
versions.extend([
VersionInfo {
version: "nightly".into(),
rolling: true,
..Default::default()
},
VersionInfo {
version: "beta".into(),
rolling: true,
..Default::default()
},
VersionInfo {
version: "stable".into(),
rolling: true,
..Default::default()
},
]);
Ok(versions)
}
fn is_rolling_channel(&self, version: &str) -> bool {
version == "nightly"
}
fn latest_installed_channel_version(&self, channel: &str) -> Option<String> {
if !self.is_rolling_channel(channel) {
return None;
}
latest_installed_nightly(self.list_installed_versions().into_iter())
}
async fn resolve_channel_version(
&self,
_config: &Arc<Config>,
version: &str,
) -> Result<Option<String>> {
if !self.is_rolling_channel(version) {
return Ok(None);
}
current_nightly_version().await.map(Some)
}
fn requires_concrete_channel_version(&self, version: &str) -> bool {
self.is_rolling_channel(version)
}
fn is_exact_version(&self, version: &str) -> bool {
is_dated_nightly(version)
}
async fn resolve_exact_version(
&self,
_config: &Arc<Config>,
version: &str,
) -> Result<Option<String>> {
Ok(is_dated_nightly(version).then(|| version.to_string()))
}
async fn _parse_idiomatic_file(&self, path: &Path) -> Result<Vec<String>> {
Ok(self
._parse_idiomatic_file_with_options(path)
.await?
.into_iter()
.map(|(version, _)| version)
.collect())
}
async fn _parse_idiomatic_file_with_options(
&self,
path: &Path,
) -> Result<Vec<IdiomaticVersion>> {
let rt = parse_idiomatic_file(path)?;
if rt.channel.is_empty() {
return Ok(vec![]);
}
let options = rt.apply_to_options(ToolVersionOptions::default());
Ok(vec![(
rt.channel.clone(),
(!options.is_empty()).then_some(options),
)])
}
async fn install_version_(&self, ctx: &InstallContext, tv: ToolVersion) -> Result<ToolVersion> {
let runtime = RustRuntime::resolve_for_tool_version(&ctx.config, &tv).await?;
let _state_locks = lock_rust_state(&runtime.homes).await?;
self.setup_rustup(ctx, &tv, &runtime).await?;
let raw_opts = tv.request.options();
let (profile, components, targets) = RustOptions::new(&raw_opts).install_args();
let mut cmd = CmdLineRunner::new(runtime.bin_dir.join(RUSTUP_BIN))
.with_pr(ctx.pr.as_ref())
.arg("toolchain")
.arg("install")
.arg(&tv.version)
.opt_args("--component", components)
.opt_args("--target", targets)
.prepend_path(vec![runtime.bin_dir.clone()])?
.env_values(tv.install_env())
.envs(rustup_env(&runtime.homes, &tv.version));
if let Some(profile) = profile.as_ref() {
cmd = cmd.arg("--profile").arg(profile);
}
cmd.execute()?;
file::remove_all(tv.install_path())?;
file::make_symlink(&runtime.bin_dir, &tv.install_path())?;
self.test_rust(ctx, &tv, &runtime).await?;
Ok(tv)
}
async fn uninstall_version_impl(
&self,
config: &Arc<Config>,
pr: &dyn SingleReport,
tv: &ToolVersion,
) -> Result<()> {
let runtime = RustRuntime::resolve_recorded_for_tool_version(config, tv).await?;
let mut env = rustup_env(&runtime.homes, &tv.version);
env.remove("RUSTUP_TOOLCHAIN");
CmdLineRunner::new(runtime.bin_dir.join(RUSTUP_BIN))
.with_pr(pr)
.arg("toolchain")
.arg("uninstall")
.arg(&tv.version)
.prepend_path(vec![runtime.bin_dir])?
.envs(env)
.execute()
}
async fn list_bin_paths(&self, config: &Arc<Config>, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
Ok(vec![
RustRuntime::resolve_recorded_for_tool_version(config, tv)
.await?
.bin_dir,
])
}
async fn exec_env(
&self,
config: &Arc<Config>,
_ts: &Toolset,
tv: &ToolVersion,
) -> Result<BTreeMap<String, String>> {
Ok(rustup_env(&RustHomes::resolve(config).await?, &tv.version))
}
async fn outdated_info(
&self,
config: &Arc<Config>,
tv: &ToolVersion,
bump: bool,
opts: &ResolveOptions,
) -> Result<Option<OutdatedInfo>> {
let requested = tv.request.version();
if requested == "nightly" {
if Settings::get().offline() || opts.offline {
let oi = OutdatedInfo::new(config, tv.clone(), tv.version.clone())?;
return Ok((oi.current.as_ref() != Some(&tv.version)).then_some(oi));
}
let latest = current_nightly_version().await?;
let oi = OutdatedInfo::new(config, tv.clone(), latest.clone())?;
return Ok((oi.current.as_ref() != Some(&latest)).then_some(oi));
}
if is_dated_nightly(&requested) {
let latest = if bump && !Settings::get().offline() && !opts.offline {
current_nightly_version().await?
} else {
tv.version.clone()
};
let mut oi = OutdatedInfo::new(config, tv.clone(), latest.clone())?;
if bump && requested != latest {
oi.bump = Some(latest.clone());
oi.tool_request = ToolRequest::new_with_options(
tv.request.ba().clone(),
&latest,
tv.request.options(),
tv.request.source().clone(),
)?;
}
return Ok((oi.current.as_ref() != Some(&latest)).then_some(oi));
}
let v_re = regex!(r#"Update available : (.*) -> (.*)"#);
if regex!(r"(\d+)\.(\d+)\.(\d+)").is_match(&tv.version) {
let oi = OutdatedInfo::resolve(config, tv.clone(), bump, opts).await?;
Ok(oi)
} else {
let ts = config.get_toolset().await?;
let runtime = RustRuntime::resolve_recorded_for_tool_version(config, tv).await?;
let mut cmd = cmd(runtime.bin_dir.join(RUSTUP_BIN), ["check"])
.env("PATH", self.path_env_for_cmd(config, tv).await?);
for (k, v) in self.exec_env(config, ts, tv).await? {
cmd = cmd.env(k, v);
}
let result = cmd.stdout_capture().stderr_capture().unchecked().run()?;
let exit_code = result.status.code().unwrap_or(-1);
if exit_code != 0 && exit_code != 100 {
let stderr = String::from_utf8_lossy(&result.stderr);
eyre::bail!(
"command [\"rustup\", \"check\"] exited with code {}. stderr: {}",
exit_code,
stderr.trim()
);
}
let out = String::from_utf8_lossy(&result.stdout);
for line in out.lines() {
if line.starts_with(&self.target_triple(tv))
&& let Some(_cap) = v_re.captures(line)
{
let oi = OutdatedInfo::new(config, tv.clone(), tv.version.clone())?;
return Ok(Some(oi));
}
}
Ok(None)
}
}
fn uses_custom_outdated_info(&self) -> bool {
true
}
}
#[derive(Debug, Default)]
struct RustToolchain {
channel: String,
profile: Option<String>,
components: Option<Vec<String>>,
targets: Option<Vec<String>>,
}
impl RustToolchain {
fn apply_to_options(&self, options: ToolVersionOptions) -> ToolVersionOptions {
let mut opts = options;
if let Some(profile) = &self.profile {
opts.opts
.insert("profile".into(), toml::Value::String(profile.clone()));
}
if let Some(components) = &self.components {
opts.opts
.insert("components".into(), string_array(components));
}
if let Some(targets) = &self.targets {
opts.opts.insert("targets".into(), string_array(targets));
}
opts
}
}
fn string_array(values: &[String]) -> toml::Value {
toml::Value::Array(
values
.iter()
.map(|value| toml::Value::String(value.clone()))
.collect(),
)
}
fn parse_idiomatic_file(path: &Path) -> Result<RustToolchain> {
let content = file::read_to_string(path)?;
let toml: toml::Value = toml::de::from_str(&content)?;
let mut rt = RustToolchain::default();
if let Some(toolchain) = toml.get("toolchain") {
if let Some(channel) = toolchain.get("channel") {
rt.channel = channel.as_str().unwrap().to_string();
}
if let Some(profile) = toolchain.get("profile") {
rt.profile = Some(profile.as_str().unwrap().to_string());
}
if let Some(components) = toolchain.get("components") {
let components = components
.as_array()
.unwrap()
.iter()
.map(|c| c.as_str().unwrap().to_string())
.collect::<Vec<_>>();
if !components.is_empty() {
rt.components = Some(components);
}
}
if let Some(targets) = toolchain.get("targets") {
let targets = targets
.as_array()
.unwrap()
.iter()
.map(|c| c.as_str().unwrap().to_string())
.collect::<Vec<_>>();
if !targets.is_empty() {
rt.targets = Some(targets);
}
}
}
Ok(rt)
}
#[cfg(unix)]
const RUSTC_BIN: &str = "rustc";
#[cfg(windows)]
const RUSTC_BIN: &str = "rustc.exe";
#[cfg(unix)]
const RUSTUP_INIT_BIN: &str = "rustup-init";
#[cfg(windows)]
const RUSTUP_INIT_BIN: &str = "rustup-init.exe";
#[cfg(unix)]
const RUSTUP_BIN: &str = "rustup";
#[cfg(windows)]
const RUSTUP_BIN: &str = "rustup.exe";
#[cfg(unix)]
const CARGO_BIN: &str = "cargo";
#[cfg(windows)]
const CARGO_BIN: &str = "cargo.exe";
#[cfg(unix)]
fn rustup_url(_settings: &Settings) -> String {
"https://sh.rustup.rs".to_string()
}
#[cfg(windows)]
fn rustup_url(settings: &Settings) -> String {
let arch = match settings.arch() {
"x64" => "x86_64",
"arm64" => "aarch64",
other => other,
};
format!("https://win.rustup.rs/{arch}")
}
fn rustup_path() -> PathBuf {
dirs::CACHE.join("rust").join(RUSTUP_INIT_BIN)
}
fn rust_state_lock_identities(rustup_home: &Path, cargo_home: &Path) -> Vec<PathBuf> {
let mut identities = vec![
file::desymlink_path(rustup_home),
file::desymlink_path(cargo_home),
];
identities.sort();
identities.dedup();
identities
}
async fn lock_rust_state(homes: &RustHomes) -> Result<Vec<fslock::LockFile>> {
let identities = rust_state_lock_identities(&homes.rustup, &homes.cargo);
tokio::task::spawn_blocking(move || {
identities
.into_iter()
.map(|identity| {
let display_identity = identity.clone();
LockFile::new(&identity)
.with_callback(move |_| {
debug!(
"waiting for Rust state lock on {}",
file::display_path(&display_identity)
);
})
.lock()
})
.collect()
})
.await?
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RustHomes {
cargo: PathBuf,
rustup: PathBuf,
explicit: bool,
}
impl RustHomes {
async fn resolve(config: &Arc<Config>) -> Result<Self> {
let config_env = config.env().await?;
let settings = Settings::get();
Ok(Self::from_sources(
&config_env,
settings.rust.cargo_home.clone(),
env::var_path("CARGO_HOME"),
settings.rust.rustup_home.clone(),
env::var_path("RUSTUP_HOME"),
))
}
fn from_sources(
config_env: &IndexMap<String, String>,
configured_cargo: Option<PathBuf>,
ambient_cargo: Option<PathBuf>,
configured_rustup: Option<PathBuf>,
ambient_rustup: Option<PathBuf>,
) -> Self {
let cargo_explicit = config_env.contains_key("CARGO_HOME")
|| config_env.contains_key("MISE_CARGO_HOME")
|| configured_cargo.is_some()
|| ambient_cargo.is_some();
let rustup_explicit = config_env.contains_key("RUSTUP_HOME")
|| config_env.contains_key("MISE_RUSTUP_HOME")
|| configured_rustup.is_some()
|| ambient_rustup.is_some();
Self {
cargo: select_rust_home(
config_env,
"CARGO_HOME",
"MISE_CARGO_HOME",
configured_cargo,
ambient_cargo,
dirs::HOME.join(".cargo"),
),
rustup: select_rust_home(
config_env,
"RUSTUP_HOME",
"MISE_RUSTUP_HOME",
configured_rustup,
ambient_rustup,
dirs::HOME.join(".rustup"),
),
explicit: cargo_explicit || rustup_explicit,
}
}
fn cargo_bindir(&self) -> PathBuf {
self.cargo.join("bin")
}
fn cargo_bin(&self) -> PathBuf {
self.cargo_bindir().join(CARGO_BIN)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RustProvider {
Managed,
External,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RustRuntime {
homes: RustHomes,
bin_dir: PathBuf,
provider: RustProvider,
}
impl RustRuntime {
async fn resolve_for_tool_version(config: &Arc<Config>, tv: &ToolVersion) -> Result<Self> {
let homes = RustHomes::resolve(config).await?;
Ok(Self::from_paths_with_install(
homes,
&env::PATH,
&tv.install_path(),
rustup_proxy_dir_is_usable,
))
}
async fn resolve_recorded_for_tool_version(
config: &Arc<Config>,
tv: &ToolVersion,
) -> Result<Self> {
let homes = RustHomes::resolve(config).await?;
Ok(Self::from_paths_with_install(
homes,
&env::PATH,
&tv.install_path(),
rustup_proxy_dir_is_usable,
))
}
fn from_paths_with(
homes: RustHomes,
paths: &[PathBuf],
provider_is_usable: impl Fn(&Path) -> bool,
) -> Self {
if !rustup_is_initialized(&homes)
&& !homes.explicit
&& let Some(bin_dir) = external_rustup_bin_dir(paths, provider_is_usable)
{
debug!(
"using external rustup proxies from {}",
file::display_path(&bin_dir)
);
return Self {
homes,
bin_dir,
provider: RustProvider::External,
};
}
let bin_dir = homes.cargo_bindir();
Self {
homes,
bin_dir,
provider: RustProvider::Managed,
}
}
fn from_paths_with_install(
homes: RustHomes,
paths: &[PathBuf],
install_path: &Path,
provider_is_usable: impl Fn(&Path) -> bool,
) -> Self {
if !homes.explicit
&& let Some(bin_dir) =
recorded_external_rustup_bin_dir(&homes, install_path, &provider_is_usable)
{
debug!(
"using recorded external rustup proxies from {}",
file::display_path(&bin_dir)
);
return Self {
homes,
bin_dir,
provider: RustProvider::External,
};
}
Self::from_paths_with(homes, paths, provider_is_usable)
}
fn is_external(&self) -> bool {
self.provider == RustProvider::External
}
}
fn recorded_external_rustup_bin_dir(
homes: &RustHomes,
install_path: &Path,
provider_is_usable: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
file::resolve_symlink(install_path)
.ok()
.flatten()
.map(|target| {
if target.is_absolute() {
target
} else {
install_path.parent().unwrap_or(Path::new(".")).join(target)
}
})
.filter(|target| target != &homes.cargo_bindir())
.filter(|target| provider_is_usable(target))
}
fn external_rustup_bin_dir(
paths: &[PathBuf],
provider_is_usable: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
paths
.iter()
.filter(|path| !file::is_mise_dispatch_dir(path))
.find(|path| {
[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]
.iter()
.all(|bin| file::is_executable(&path.join(bin)))
&& provider_is_usable(path)
})
.cloned()
}
fn rustup_proxy_dir_is_usable(bin_dir: &Path) -> bool {
if !Command::new(bin_dir.join(RUSTUP_BIN))
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
{
return false;
}
const PROBE_TOOLCHAIN: &str = "mise-proxy-probe:invalid";
[CARGO_BIN, RUSTC_BIN].iter().all(|bin| {
Command::new(bin_dir.join(bin))
.arg("--version")
.env("RUSTUP_TOOLCHAIN", PROBE_TOOLCHAIN)
.env("RUSTUP_AUTO_INSTALL", "0")
.output()
.is_ok_and(|output| {
!output.status.success()
&& (String::from_utf8_lossy(&output.stdout).contains(PROBE_TOOLCHAIN)
|| String::from_utf8_lossy(&output.stderr).contains(PROBE_TOOLCHAIN))
})
})
}
fn rustup_is_initialized(homes: &RustHomes) -> bool {
homes.rustup.join("settings.toml").exists() && homes.cargo_bin().exists()
}
fn select_rust_home(
config_env: &IndexMap<String, String>,
direct_key: &str,
mise_key: &str,
configured: Option<PathBuf>,
ambient: Option<PathBuf>,
default: PathBuf,
) -> PathBuf {
let path = config_env
.get(direct_key)
.or_else(|| config_env.get(mise_key))
.map(PathBuf::from)
.or(configured)
.or(ambient)
.unwrap_or(default);
resolve_rust_home(path)
}
fn resolve_rust_home(path: PathBuf) -> PathBuf {
let path = file::replace_path(path);
if path.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
} else {
path
}
}
fn rustup_env(homes: &RustHomes, toolchain: &str) -> BTreeMap<String, String> {
[
(
"CARGO_HOME".to_string(),
homes.cargo.to_string_lossy().to_string(),
),
(
"RUSTUP_HOME".to_string(),
homes.rustup.to_string_lossy().to_string(),
),
("RUSTUP_TOOLCHAIN".to_string(), toolchain.to_string()),
]
.into()
}
fn rustup_path_env(runtime: &RustRuntime) -> Result<OsString> {
Ok(env::join_paths(
std::iter::once(runtime.bin_dir.clone()).chain(env::PATH.clone()),
)?)
}
fn rustup_component_installed(installed: &BTreeSet<String>, component: &str) -> bool {
installed.iter().any(|item| {
item == component
|| item
.strip_prefix(component)
.and_then(|suffix| suffix.strip_prefix('-'))
.is_some_and(rustup_component_suffix_is_host_triple)
})
}
fn rustup_component_suffix_is_host_triple(suffix: &str) -> bool {
let Some((arch, rest)) = suffix.split_once('-') else {
return false;
};
!rest.is_empty() && RUST_TARGET_ARCHES.contains(&arch)
}
const RUST_TARGET_ARCHES: &[&str] = &[
"aarch64",
"arm",
"armeb",
"arm64ec",
"armv4t",
"armv5te",
"armv6",
"armv7",
"armv7a",
"armv7r",
"armv7s",
"avr",
"bpfeb",
"bpfel",
"csky",
"hexagon",
"i386",
"i586",
"i686",
"loongarch64",
"m68k",
"mips",
"mips64",
"mips64el",
"mipsel",
"msp430",
"nvptx64",
"powerpc",
"powerpc64",
"powerpc64le",
"riscv32",
"riscv64",
"s390x",
"sparc",
"sparc64",
"thumbv4t",
"thumbv5te",
"thumbv6m",
"thumbv7a",
"thumbv7em",
"thumbv7m",
"thumbv7neon",
"wasm32",
"wasm64",
"x86_64",
];
#[cfg(test)]
mod tests {
use super::*;
fn rust_homes_at(root: &Path, explicit: bool) -> RustHomes {
RustHomes {
cargo: root.join("cargo"),
rustup: root.join("rustup"),
explicit,
}
}
fn create_proxy_dir(path: &Path, bins: &[&str]) {
std::fs::create_dir_all(path).unwrap();
for bin in bins {
let path = path.join(bin);
std::fs::write(&path, b"proxy").unwrap();
file::make_executable(path).unwrap();
}
}
fn opts_with(key: &str, value: &str) -> ToolVersionOptions {
let mut opts = ToolVersionOptions::default();
opts.opts
.insert(key.to_string(), toml::Value::String(value.to_string()));
opts
}
#[test]
fn parses_nightly_manifest_date() {
assert_eq!(
parse_nightly_manifest("manifest-version = \"2\"\ndate = \"2026-08-13\"\n").unwrap(),
"nightly-2026-08-13"
);
}
#[test]
fn rejects_missing_or_invalid_nightly_manifest_date() {
assert!(parse_nightly_manifest("manifest-version = \"2\"\n").is_err());
assert!(parse_nightly_manifest("date = \"2026-13-40\"\n").is_err());
assert!(parse_nightly_manifest("not toml").is_err());
}
#[test]
fn recognizes_only_valid_dated_nightlies() {
assert!(is_dated_nightly("nightly-2026-08-13"));
assert!(!is_dated_nightly("nightly"));
assert!(!is_dated_nightly("nightly-2026-13-40"));
assert!(!is_dated_nightly("1.90.0"));
}
#[test]
fn selects_latest_installed_dated_nightly() {
let versions = vec![
"nightly-2026-08-11".to_string(),
"nightly".to_string(),
"1.90.0".to_string(),
"nightly-2026-08-13".to_string(),
];
assert_eq!(
latest_installed_nightly(versions.into_iter()).as_deref(),
Some("nightly-2026-08-13")
);
assert_eq!(
latest_installed_nightly(["nightly".to_string()].into_iter()),
None
);
}
#[test]
fn rust_options_reads_install_args() {
let mut opts = opts_with("profile", "minimal");
opts.opts.insert(
"components".to_string(),
toml::Value::String("clippy, rustfmt".to_string()),
);
opts.opts.insert(
"targets".to_string(),
toml::Value::String("wasm32-wasip1".to_string()),
);
let (profile, components, targets) = RustOptions::new(&opts).install_args();
assert_eq!(profile, Some("minimal".to_string()));
assert_eq!(
components,
Some(vec!["clippy".to_string(), "rustfmt".to_string()])
);
assert_eq!(targets, Some(vec!["wasm32-wasip1".to_string()]));
}
#[test]
fn rust_options_reads_array_install_args() {
let mut opts = opts_with("profile", "minimal");
opts.opts.insert(
"components".to_string(),
string_array(&[" clippy ".to_string(), "rustfmt".to_string(), String::new()]),
);
opts.opts.insert(
"targets".to_string(),
string_array(&[
"wasm32-wasip1".to_string(),
" wasm32-unknown-unknown ".to_string(),
]),
);
let (profile, components, targets) = RustOptions::new(&opts).install_args();
assert_eq!(profile, Some("minimal".to_string()));
assert_eq!(
components,
Some(vec!["clippy".to_string(), "rustfmt".to_string()])
);
assert_eq!(
targets,
Some(vec![
"wasm32-wasip1".to_string(),
"wasm32-unknown-unknown".to_string()
])
);
}
#[test]
fn rustup_component_matching_allows_host_suffixes() {
let installed = BTreeSet::from([
"rust-src".to_string(),
"llvm-tools-x86_64-unknown-linux-gnu".to_string(),
]);
assert!(rustup_component_installed(&installed, "rust-src"));
assert!(rustup_component_installed(&installed, "llvm-tools"));
assert!(!rustup_component_installed(&installed, "rustfmt"));
assert!(!rustup_component_installed(&installed, "rust"));
assert!(!rustup_component_installed(&installed, "llvm"));
}
#[test]
fn rust_idiomatic_options_override_tool_options() {
let opts = opts_with("profile", "minimal");
let rt = RustToolchain {
profile: Some("default".to_string()),
..Default::default()
};
let opts = rt.apply_to_options(opts);
let (profile, _, _) = RustOptions::new(&opts).install_args();
assert_eq!(profile, Some("default".to_string()));
}
#[tokio::test]
async fn rust_idiomatic_options_are_tool_options() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("rust-toolchain.toml");
std::fs::write(
&path,
r#"
[toolchain]
channel = "1.85.0"
profile = "minimal"
components = [" rustfmt ", "clippy", "clippy"]
targets = ["wasm32-wasip1", " wasm32-wasip1 "]
"#,
)?;
let plugin = RustPlugin::new();
let versions = plugin.parse_idiomatic_file_with_options(&path).await?;
let (version, options) = versions.into_iter().next().unwrap();
assert_eq!(version, "1.85.0");
assert_eq!(
RustOptions::new(&options).lockfile_options(),
BTreeMap::from([
("components".to_string(), "clippy,rustfmt".to_string()),
("profile".to_string(), "minimal".to_string()),
("targets".to_string(), "wasm32-wasip1".to_string()),
])
);
Ok(())
}
#[test]
fn rust_lockfile_options_include_install_args() {
let mut opts = opts_with("profile", "minimal");
opts.opts.insert(
"components".to_string(),
toml::Value::String("clippy, rustfmt".to_string()),
);
opts.opts.insert(
"targets".to_string(),
toml::Value::String("wasm32-wasip1".to_string()),
);
assert_eq!(
RustOptions::new(&opts).lockfile_options(),
BTreeMap::from([
("components".to_string(), "clippy,rustfmt".to_string()),
("profile".to_string(), "minimal".to_string()),
("targets".to_string(), "wasm32-wasip1".to_string()),
])
);
}
#[test]
fn rust_idiomatic_options_override_inline_options() {
let opts = opts_with("profile", "minimal");
let rt = RustToolchain {
profile: Some("default".to_string()),
components: Some(vec!["rustfmt".to_string()]),
..Default::default()
};
let opts = rt.apply_to_options(opts);
assert_eq!(
RustOptions::new(&opts).lockfile_options(),
BTreeMap::from([
("components".to_string(), "rustfmt".to_string()),
("profile".to_string(), "default".to_string()),
])
);
}
#[test]
fn rust_lockfile_options_skip_empty_profile() {
let opts = opts_with("profile", "");
assert_eq!(RustOptions::new(&opts).lockfile_options(), BTreeMap::new());
}
#[test]
fn rust_home_paths_expand_tilde() {
assert_eq!(
resolve_rust_home(PathBuf::from("~/.cargo-custom")),
dirs::HOME.join(".cargo-custom")
);
assert_eq!(
resolve_rust_home(PathBuf::from("~/.rustup-custom")),
dirs::HOME.join(".rustup-custom")
);
}
#[test]
fn rust_state_locks_are_shared_by_either_home() {
let first = rust_state_lock_identities(Path::new("/rustup/shared"), Path::new("/cargo/a"));
let same_rustup =
rust_state_lock_identities(Path::new("/rustup/shared"), Path::new("/cargo/b"));
let same_cargo =
rust_state_lock_identities(Path::new("/rustup/other"), Path::new("/cargo/a"));
let separate =
rust_state_lock_identities(Path::new("/rustup/other"), Path::new("/cargo/b"));
assert!(first.iter().any(|identity| same_rustup.contains(identity)));
assert!(first.iter().any(|identity| same_cargo.contains(identity)));
assert!(!first.iter().any(|identity| separate.contains(identity)));
}
#[test]
fn rust_state_locks_are_ordered_and_deduplicated() {
let cargo = file::desymlink_path(Path::new("/a/cargo"));
let rustup = file::desymlink_path(Path::new("/z/rustup"));
assert_eq!(
rust_state_lock_identities(Path::new("/z/rustup"), Path::new("/a/cargo")),
vec![cargo, rustup]
);
assert_eq!(
rust_state_lock_identities(Path::new("/shared"), Path::new("/shared")),
vec![file::desymlink_path(Path::new("/shared"))]
);
}
#[cfg(unix)]
#[test]
fn rust_state_locks_resolve_filesystem_aliases() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let state = root.path().join("state");
let alias = root.path().join("alias");
std::fs::create_dir_all(&state).unwrap();
symlink(&state, &alias).unwrap();
assert_eq!(
rust_state_lock_identities(&state, &state),
rust_state_lock_identities(&alias, &alias)
);
}
#[test]
fn rust_homes_use_defaults_without_overrides() {
let homes = RustHomes::from_sources(&indexmap::IndexMap::new(), None, None, None, None);
assert_eq!(homes.cargo, dirs::HOME.join(".cargo"));
assert_eq!(homes.rustup, dirs::HOME.join(".rustup"));
assert!(!homes.explicit);
}
#[test]
fn rust_homes_follow_config_environment_precedence() {
let config_env = indexmap::IndexMap::from([
(
"MISE_CARGO_HOME".to_string(),
"/config/mise-cargo".to_string(),
),
("CARGO_HOME".to_string(), "/config/cargo".to_string()),
(
"MISE_RUSTUP_HOME".to_string(),
"/config/mise-rustup".to_string(),
),
("RUSTUP_HOME".to_string(), "/config/rustup".to_string()),
]);
let homes = RustHomes::from_sources(
&config_env,
Some("/settings/cargo".into()),
Some("/ambient/cargo".into()),
Some("/settings/rustup".into()),
Some("/ambient/rustup".into()),
);
assert_eq!(
homes.cargo,
resolve_rust_home(PathBuf::from("/config/cargo"))
);
assert_eq!(
homes.rustup,
resolve_rust_home(PathBuf::from("/config/rustup"))
);
assert!(homes.explicit);
}
#[test]
fn rust_homes_use_config_mise_environment_before_existing_sources() {
let config_env = indexmap::IndexMap::from([(
"MISE_CARGO_HOME".to_string(),
"/config/cargo".to_string(),
)]);
let homes = RustHomes::from_sources(
&config_env,
Some("/settings/cargo".into()),
Some("/ambient/cargo".into()),
Some("/settings/rustup".into()),
Some("/ambient/rustup".into()),
);
assert_eq!(
homes.cargo,
resolve_rust_home(PathBuf::from("/config/cargo"))
);
assert_eq!(
homes.rustup,
resolve_rust_home(PathBuf::from("/settings/rustup"))
);
assert!(homes.explicit);
}
#[test]
fn rust_runtime_prefers_initialized_homes() {
let root = tempfile::tempdir().unwrap();
let homes = rust_homes_at(root.path(), false);
create_proxy_dir(&homes.cargo_bindir(), &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
std::fs::create_dir_all(&homes.rustup).unwrap();
std::fs::write(homes.rustup.join("settings.toml"), b"").unwrap();
let external = root.path().join("external/bin");
create_proxy_dir(&external, &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
let runtime = RustRuntime::from_paths_with(homes.clone(), &[external], |_| true);
assert_eq!(runtime.provider, RustProvider::Managed);
assert_eq!(runtime.bin_dir, homes.cargo_bindir());
}
#[test]
fn rust_runtime_uses_complete_external_provider() {
let root = tempfile::tempdir().unwrap();
let homes = rust_homes_at(root.path(), false);
let external = root.path().join("external/bin");
create_proxy_dir(&external, &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
let runtime =
RustRuntime::from_paths_with(homes, std::slice::from_ref(&external), |_| true);
assert_eq!(runtime.provider, RustProvider::External);
assert_eq!(runtime.bin_dir, external);
}
#[test]
fn rust_runtime_prefers_recorded_external_provider() {
let root = tempfile::tempdir().unwrap();
let homes = rust_homes_at(root.path(), false);
let recorded = root.path().join("recorded/bin");
let earlier_on_path = root.path().join("earlier/bin");
create_proxy_dir(&recorded, &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
create_proxy_dir(&earlier_on_path, &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
let install_path = root.path().join("installs/rust/1.80.0");
std::fs::create_dir_all(install_path.parent().unwrap()).unwrap();
file::make_symlink(&recorded, &install_path).unwrap();
let runtime =
RustRuntime::from_paths_with_install(homes, &[earlier_on_path], &install_path, |_| {
true
});
assert_eq!(runtime.provider, RustProvider::External);
assert_eq!(runtime.bin_dir, recorded);
}
#[test]
fn rust_runtime_keeps_recorded_external_provider_after_default_home_initialization() {
let root = tempfile::tempdir().unwrap();
let homes = rust_homes_at(root.path(), false);
create_proxy_dir(&homes.cargo_bindir(), &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
std::fs::create_dir_all(&homes.rustup).unwrap();
std::fs::write(homes.rustup.join("settings.toml"), b"").unwrap();
let recorded = root.path().join("recorded/bin");
create_proxy_dir(&recorded, &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
let install_path = root.path().join("installs/rust/1.80.0");
std::fs::create_dir_all(install_path.parent().unwrap()).unwrap();
file::make_symlink(&recorded, &install_path).unwrap();
let runtime = RustRuntime::from_paths_with_install(
homes,
std::slice::from_ref(&recorded),
&install_path,
|_| true,
);
assert_eq!(runtime.provider, RustProvider::External);
assert_eq!(runtime.bin_dir, recorded);
}
#[test]
fn rust_runtime_explicit_homes_override_recorded_external_provider() {
let root = tempfile::tempdir().unwrap();
let homes = rust_homes_at(root.path(), true);
create_proxy_dir(&homes.cargo_bindir(), &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
std::fs::create_dir_all(&homes.rustup).unwrap();
std::fs::write(homes.rustup.join("settings.toml"), b"").unwrap();
let recorded = root.path().join("recorded/bin");
create_proxy_dir(&recorded, &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
let install_path = root.path().join("installs/rust/1.80.0");
std::fs::create_dir_all(install_path.parent().unwrap()).unwrap();
file::make_symlink(&recorded, &install_path).unwrap();
let runtime = RustRuntime::from_paths_with_install(
homes.clone(),
std::slice::from_ref(&recorded),
&install_path,
|_| true,
);
assert_eq!(runtime.provider, RustProvider::Managed);
assert_eq!(runtime.bin_dir, homes.cargo_bindir());
}
#[test]
fn rust_runtime_rejects_incomplete_external_provider() {
let root = tempfile::tempdir().unwrap();
let homes = rust_homes_at(root.path(), false);
let external = root.path().join("external/bin");
create_proxy_dir(&external, &[RUSTUP_BIN, CARGO_BIN]);
let runtime = RustRuntime::from_paths_with(homes.clone(), &[external], |_| true);
assert_eq!(runtime.provider, RustProvider::Managed);
assert_eq!(runtime.bin_dir, homes.cargo_bindir());
}
#[test]
fn rust_runtime_does_not_replace_explicit_homes() {
let root = tempfile::tempdir().unwrap();
let homes = rust_homes_at(root.path(), true);
let external = root.path().join("external/bin");
create_proxy_dir(&external, &[RUSTUP_BIN, CARGO_BIN, RUSTC_BIN]);
let runtime = RustRuntime::from_paths_with(homes.clone(), &[external], |_| true);
assert_eq!(runtime.provider, RustProvider::Managed);
assert_eq!(runtime.bin_dir, homes.cargo_bindir());
}
}