use crate::error::{InstallerError, Result};
use camino::{Utf8Path, Utf8PathBuf};
use std::process::{Command, Output};
const REQUIRED_COMPONENTS: &[&str] = &["rust-src", "rustc-dev", "llvm-tools-preview"];
#[derive(Debug, Clone)]
pub struct Toolchain {
channel: String,
workspace_root: Utf8PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolchainInstallStatus {
installed_toolchain: bool,
}
impl ToolchainInstallStatus {
#[must_use]
pub fn installed_toolchain(&self) -> bool {
self.installed_toolchain
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ToolchainConfig {
channel: String,
}
#[cfg_attr(test, mockall::automock)]
trait CommandRunner {
#[expect(
clippy::needless_lifetimes,
reason = "mockall requires explicit lifetime for &[&str]"
)]
fn run<'a>(&self, program: &str, args: &[&'a str]) -> std::io::Result<Output>;
}
struct SystemCommandRunner;
impl CommandRunner for SystemCommandRunner {
#[expect(clippy::needless_lifetimes, reason = "signature must match trait")]
fn run<'a>(&self, program: &str, args: &[&'a str]) -> std::io::Result<Output> {
Command::new(program).args(args).output()
}
}
impl Toolchain {
pub fn detect(workspace_root: &Utf8Path) -> Result<Self> {
let toolchain_path = workspace_root.join("rust-toolchain.toml");
if !toolchain_path.exists() {
return Err(InstallerError::ToolchainFileNotFound {
path: toolchain_path,
});
}
let contents = std::fs::read_to_string(&toolchain_path)?;
let config = parse_toolchain_config(&contents)?;
Ok(Self {
channel: config.channel,
workspace_root: workspace_root.to_owned(),
})
}
#[must_use]
pub fn with_override(workspace_root: &Utf8Path, channel: &str) -> Self {
Self {
channel: channel.to_owned(),
workspace_root: workspace_root.to_owned(),
}
}
pub fn verify_installed(&self) -> Result<()> {
let runner = SystemCommandRunner;
if self.is_installed_with(&runner)? {
Ok(())
} else {
Err(InstallerError::ToolchainNotInstalled {
toolchain: self.channel.clone(),
})
}
}
pub fn ensure_installed(&self) -> Result<ToolchainInstallStatus> {
let runner = SystemCommandRunner;
self.ensure_installed_with(&runner)
}
fn ensure_installed_with(&self, runner: &dyn CommandRunner) -> Result<ToolchainInstallStatus> {
if self.is_installed_with(runner)? {
self.install_components_with(runner)?;
return Ok(ToolchainInstallStatus {
installed_toolchain: false,
});
}
self.install_toolchain_with(runner)?;
self.install_components_with(runner)?;
if !self.is_installed_with(runner)? {
return Err(InstallerError::ToolchainNotInstalled {
toolchain: self.channel.clone(),
});
}
Ok(ToolchainInstallStatus {
installed_toolchain: true,
})
}
#[must_use]
pub fn channel(&self) -> &str {
&self.channel
}
#[must_use]
pub fn workspace_root(&self) -> &Utf8Path {
&self.workspace_root
}
fn is_installed_with(&self, runner: &dyn CommandRunner) -> Result<bool> {
let output = run_rustup(runner, &["run", &self.channel, "rustc", "--version"])?;
Ok(output.status.success())
}
fn install_toolchain_with(&self, runner: &dyn CommandRunner) -> Result<()> {
let output = run_rustup(runner, &["toolchain", "install", &self.channel])?;
if output.status.success() {
return Ok(());
}
Err(InstallerError::ToolchainInstallFailed {
toolchain: self.channel.clone(),
message: stderr_message(&output),
})
}
fn install_components_with(&self, runner: &dyn CommandRunner) -> Result<()> {
let mut args: Vec<&str> = vec!["component", "add", "--toolchain", &self.channel];
args.extend(REQUIRED_COMPONENTS);
let output = run_rustup(runner, &args)?;
if output.status.success() {
return Ok(());
}
Err(InstallerError::ToolchainComponentInstallFailed {
toolchain: self.channel.clone(),
components: REQUIRED_COMPONENTS.join(", "),
message: stderr_message(&output),
})
}
}
pub fn parse_toolchain_channel(contents: &str) -> Result<String> {
parse_toolchain_config(contents).map(|config| config.channel)
}
fn parse_toolchain_config(contents: &str) -> Result<ToolchainConfig> {
let table: toml::Table =
contents
.parse()
.map_err(|e| InstallerError::InvalidToolchainFile {
reason: format!("TOML parse error: {e}"),
})?;
let channel = parse_channel_from_table(&table)?;
Ok(ToolchainConfig { channel })
}
fn parse_channel_from_table(table: &toml::Table) -> Result<String> {
let channel_from_toolchain = table
.get("toolchain")
.and_then(|t| t.get("channel"))
.and_then(|c| c.as_str());
if let Some(s) = channel_from_toolchain {
return Ok(s.to_owned());
}
let channel_from_top = table.get("channel").and_then(|c| c.as_str());
if let Some(s) = channel_from_top {
return Ok(s.to_owned());
}
Err(InstallerError::InvalidToolchainFile {
reason: "no channel field found in rust-toolchain.toml".to_owned(),
})
}
fn run_rustup(runner: &dyn CommandRunner, args: &[&str]) -> Result<Output> {
runner
.run("rustup", args)
.map_err(|e| InstallerError::ToolchainDetection {
reason: format!("failed to run rustup: {e}"),
})
}
fn stderr_message(output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let trimmed = stderr.trim();
if trimmed.is_empty() {
"unknown error".to_owned()
} else {
trimmed.to_owned()
}
}
#[cfg(test)]
mod tests;