use std::path::{Path, PathBuf};
use std::{collections::BTreeMap, 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;
use crate::config::{Config, Settings};
use crate::http::HTTP;
use crate::install_context::InstallContext;
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::Result;
use xx::regex;
#[derive(Debug)]
pub struct RustPlugin {
ba: Arc<BackendArg>,
}
#[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>> {
match self.values.raw().opts.get(name) {
Some(toml::Value::String(value)) => Some(
value
.split(',')
.map(|value| value.trim().to_string())
.collect(),
),
Some(toml::Value::Array(values)) => Some(
values
.iter()
.filter_map(|value| value.as_str().map(|value| value.trim().to_string()))
.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 fn new() -> Self {
Self {
ba: plugins::core::new_backend_arg("rust").into(),
}
}
async fn setup_rustup(&self, ctx: &InstallContext, tv: &ToolVersion) -> Result<()> {
let settings = Settings::get();
if rustup_home().join("settings.toml").exists() && cargo_bin().exists() {
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(rustup_home())?;
let ts = ctx.config.get_toolset().await?;
let mut cmd = CmdLineRunner::new(rustup_path())
.with_pr(ctx.pr.as_ref())
.arg("--no-modify-path")
.arg("--default-toolchain")
.arg("none")
.arg("-y")
.envs(tv.install_env())
.envs(self.exec_env(&ctx.config, ts, tv).await?);
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) -> Result<()> {
ctx.pr.set_message(format!("{RUSTC_BIN} -V"));
let ts = ctx.config.get_toolset().await?;
CmdLineRunner::new(RUSTC_BIN)
.with_pr(ctx.pr.as_ref())
.arg("-V")
.envs(tv.install_env())
.envs(self.exec_env(&ctx.config, ts, tv).await?)
.prepend_path(self.list_bin_paths(&ctx.config, tv).await?)?
.execute()
}
fn target_triple(&self, tv: &ToolVersion) -> String {
format!("{}-{}", tv.version, TARGET)
}
}
#[async_trait]
impl Backend for RustPlugin {
fn ba(&self) -> &Arc<BackendArg> {
&self.ba
}
fn supports_lockfile_url(&self) -> bool {
false
}
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 versions: Vec<VersionInfo> = github::list_releases("rust-lang/rust")
.await?
.into_iter()
.map(|r| VersionInfo {
release_url: Some(format!("https://releases.rs/docs/{}/", r.tag_name)),
version: r.tag_name,
created_at: Some(r.created_at),
..Default::default()
})
.rev()
.chain(vec![
VersionInfo {
version: "nightly".into(),
rolling: true,
..Default::default()
},
VersionInfo {
version: "beta".into(),
rolling: true,
..Default::default()
},
VersionInfo {
version: "stable".into(),
rolling: true,
..Default::default()
},
])
.collect();
Ok(versions)
}
async fn _idiomatic_filenames(&self) -> Result<Vec<String>> {
Ok(vec!["rust-toolchain.toml".into()])
}
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> {
self.setup_rustup(ctx, &tv).await?;
let ts = ctx.config.get_toolset().await?;
let raw_opts = tv.request.options();
let (profile, components, targets) = RustOptions::new(&raw_opts).install_args();
let mut cmd = CmdLineRunner::new(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(self.list_bin_paths(&ctx.config, &tv).await?)?
.envs(tv.install_env())
.envs(self.exec_env(&ctx.config, ts, &tv).await?);
if let Some(profile) = profile.as_ref() {
cmd = cmd.arg("--profile").arg(profile);
}
cmd.execute()?;
file::remove_all(tv.install_path())?;
file::make_symlink(&cargo_home().join("bin"), &tv.install_path())?;
self.test_rust(ctx, &tv).await?;
Ok(tv)
}
async fn uninstall_version_impl(
&self,
config: &Arc<Config>,
pr: &dyn SingleReport,
tv: &ToolVersion,
) -> Result<()> {
let ts = config.get_toolset().await?;
let mut env = self.exec_env(config, ts, tv).await?;
env.remove("RUSTUP_TOOLCHAIN");
CmdLineRunner::new(RUSTUP_BIN)
.with_pr(pr)
.arg("toolchain")
.arg("uninstall")
.arg(&tv.version)
.prepend_path(self.list_bin_paths(config, tv).await?)?
.envs(env)
.execute()
}
async fn list_bin_paths(
&self,
_config: &Arc<Config>,
_tv: &ToolVersion,
) -> Result<Vec<PathBuf>> {
Ok(vec![cargo_bindir()])
}
async fn exec_env(
&self,
_config: &Arc<Config>,
_ts: &Toolset,
tv: &ToolVersion,
) -> Result<BTreeMap<String, String>> {
let toolchain = tv.version.to_string();
Ok([
(
"CARGO_HOME".to_string(),
cargo_home().to_string_lossy().to_string(),
),
(
"RUSTUP_HOME".to_string(),
rustup_home().to_string_lossy().to_string(),
),
("RUSTUP_TOOLCHAIN".to_string(), toolchain),
]
.into())
}
async fn outdated_info(
&self,
config: &Arc<Config>,
tv: &ToolVersion,
bump: bool,
opts: &ResolveOptions,
) -> Result<Option<OutdatedInfo>> {
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 mut cmd =
cmd!(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)
}
}
}
#[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 rustup_home() -> PathBuf {
let path = Settings::get()
.rust
.rustup_home
.clone()
.or(env::var_path("RUSTUP_HOME"))
.unwrap_or(dirs::HOME.join(".rustup"));
if path.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
} else {
path
}
}
fn cargo_home() -> PathBuf {
let path = Settings::get()
.rust
.cargo_home
.clone()
.or(env::var_path("CARGO_HOME"))
.unwrap_or(dirs::HOME.join(".cargo"));
if path.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
} else {
path
}
}
fn cargo_bin() -> PathBuf {
cargo_bindir().join(CARGO_BIN)
}
fn cargo_bindir() -> PathBuf {
cargo_home().join("bin")
}
#[cfg(test)]
mod tests {
use super::*;
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 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_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());
}
}