feat(wfe-rustlang): add Rust toolchain step executors

New crate providing cargo and rustup step types for WFE workflows:

Cargo steps: build, test, check, clippy, fmt, doc, publish
Rustup steps: rust-install, rustup-toolchain, rustup-component, rustup-target

Shared CargoConfig base with toolchain, package, features, release,
target, profile, extra_args, env, working_dir, and timeout support.
Toolchain override via rustup run for any cargo command.
This commit is contained in:
2026-03-29 16:56:07 +01:00
parent a7c2eb1d9b
commit 0cb26df68b
8 changed files with 1411 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
use serde::{Deserialize, Serialize};
/// Which rustup operation to perform.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum RustupCommand {
/// Install Rust via rustup-init.
Install,
/// Install a toolchain (`rustup toolchain install`).
ToolchainInstall,
/// Add a component (`rustup component add`).
ComponentAdd,
/// Add a compilation target (`rustup target add`).
TargetAdd,
}
impl RustupCommand {
pub fn as_str(&self) -> &'static str {
match self {
Self::Install => "install",
Self::ToolchainInstall => "toolchain-install",
Self::ComponentAdd => "component-add",
Self::TargetAdd => "target-add",
}
}
}
/// Configuration for rustup step types.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RustupConfig {
pub command: RustupCommand,
/// Toolchain to install or scope components/targets to (e.g. "nightly", "1.78.0").
#[serde(default)]
pub toolchain: Option<String>,
/// Components to add (e.g. ["clippy", "rustfmt", "rust-src"]).
#[serde(default)]
pub components: Vec<String>,
/// Compilation targets to add (e.g. ["wasm32-unknown-unknown"]).
#[serde(default)]
pub targets: Vec<String>,
/// Rustup profile for initial install: "minimal", "default", or "complete".
#[serde(default)]
pub profile: Option<String>,
/// Default toolchain to set during install.
#[serde(default)]
pub default_toolchain: Option<String>,
/// Additional arguments appended to the command.
#[serde(default)]
pub extra_args: Vec<String>,
/// Execution timeout in milliseconds.
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn command_as_str() {
assert_eq!(RustupCommand::Install.as_str(), "install");
assert_eq!(RustupCommand::ToolchainInstall.as_str(), "toolchain-install");
assert_eq!(RustupCommand::ComponentAdd.as_str(), "component-add");
assert_eq!(RustupCommand::TargetAdd.as_str(), "target-add");
}
#[test]
fn command_serde_kebab_case() {
let json = r#""toolchain-install""#;
let cmd: RustupCommand = serde_json::from_str(json).unwrap();
assert_eq!(cmd, RustupCommand::ToolchainInstall);
let serialized = serde_json::to_string(&RustupCommand::ComponentAdd).unwrap();
assert_eq!(serialized, r#""component-add""#);
}
#[test]
fn serde_round_trip_install() {
let config = RustupConfig {
command: RustupCommand::Install,
toolchain: None,
components: vec![],
targets: vec![],
profile: Some("minimal".to_string()),
default_toolchain: Some("stable".to_string()),
extra_args: vec![],
timeout_ms: Some(300_000),
};
let json = serde_json::to_string(&config).unwrap();
let de: RustupConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.command, RustupCommand::Install);
assert_eq!(de.profile, Some("minimal".to_string()));
assert_eq!(de.default_toolchain, Some("stable".to_string()));
assert_eq!(de.timeout_ms, Some(300_000));
}
#[test]
fn serde_round_trip_toolchain_install() {
let config = RustupConfig {
command: RustupCommand::ToolchainInstall,
toolchain: Some("nightly-2024-06-01".to_string()),
components: vec![],
targets: vec![],
profile: None,
default_toolchain: None,
extra_args: vec![],
timeout_ms: None,
};
let json = serde_json::to_string(&config).unwrap();
let de: RustupConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.command, RustupCommand::ToolchainInstall);
assert_eq!(de.toolchain, Some("nightly-2024-06-01".to_string()));
}
#[test]
fn serde_round_trip_component_add() {
let config = RustupConfig {
command: RustupCommand::ComponentAdd,
toolchain: Some("nightly".to_string()),
components: vec!["clippy".to_string(), "rustfmt".to_string(), "rust-src".to_string()],
targets: vec![],
profile: None,
default_toolchain: None,
extra_args: vec![],
timeout_ms: None,
};
let json = serde_json::to_string(&config).unwrap();
let de: RustupConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.command, RustupCommand::ComponentAdd);
assert_eq!(de.components, vec!["clippy", "rustfmt", "rust-src"]);
assert_eq!(de.toolchain, Some("nightly".to_string()));
}
#[test]
fn serde_round_trip_target_add() {
let config = RustupConfig {
command: RustupCommand::TargetAdd,
toolchain: Some("stable".to_string()),
components: vec![],
targets: vec!["wasm32-unknown-unknown".to_string(), "aarch64-linux-android".to_string()],
profile: None,
default_toolchain: None,
extra_args: vec![],
timeout_ms: None,
};
let json = serde_json::to_string(&config).unwrap();
let de: RustupConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.command, RustupCommand::TargetAdd);
assert_eq!(de.targets, vec!["wasm32-unknown-unknown", "aarch64-linux-android"]);
}
#[test]
fn config_defaults() {
let json = r#"{"command": "install"}"#;
let config: RustupConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.command, RustupCommand::Install);
assert!(config.toolchain.is_none());
assert!(config.components.is_empty());
assert!(config.targets.is_empty());
assert!(config.profile.is_none());
assert!(config.default_toolchain.is_none());
assert!(config.extra_args.is_empty());
assert!(config.timeout_ms.is_none());
}
#[test]
fn serde_with_extra_args() {
let config = RustupConfig {
command: RustupCommand::ToolchainInstall,
toolchain: Some("nightly".to_string()),
components: vec![],
targets: vec![],
profile: Some("minimal".to_string()),
default_toolchain: None,
extra_args: vec!["--force".to_string()],
timeout_ms: None,
};
let json = serde_json::to_string(&config).unwrap();
let de: RustupConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.extra_args, vec!["--force"]);
}
}

View File

@@ -0,0 +1,5 @@
pub mod config;
pub mod step;
pub use config::{RustupCommand, RustupConfig};
pub use step::RustupStep;

View File

@@ -0,0 +1,357 @@
use async_trait::async_trait;
use wfe_core::models::ExecutionResult;
use wfe_core::traits::step::{StepBody, StepExecutionContext};
use wfe_core::WfeError;
use crate::rustup::config::{RustupCommand, RustupConfig};
pub struct RustupStep {
config: RustupConfig,
}
impl RustupStep {
pub fn new(config: RustupConfig) -> Self {
Self { config }
}
pub fn build_command(&self) -> tokio::process::Command {
match self.config.command {
RustupCommand::Install => self.build_install_command(),
RustupCommand::ToolchainInstall => self.build_toolchain_install_command(),
RustupCommand::ComponentAdd => self.build_component_add_command(),
RustupCommand::TargetAdd => self.build_target_add_command(),
}
}
fn build_install_command(&self) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("sh");
// Pipe rustup-init through sh with non-interactive flag.
let mut script = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y".to_string();
if let Some(ref profile) = self.config.profile {
script.push_str(&format!(" --profile {profile}"));
}
if let Some(ref tc) = self.config.default_toolchain {
script.push_str(&format!(" --default-toolchain {tc}"));
}
for arg in &self.config.extra_args {
script.push_str(&format!(" {arg}"));
}
cmd.arg("-c").arg(&script);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd
}
fn build_toolchain_install_command(&self) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("rustup");
cmd.args(["toolchain", "install"]);
if let Some(ref tc) = self.config.toolchain {
cmd.arg(tc);
}
if let Some(ref profile) = self.config.profile {
cmd.args(["--profile", profile]);
}
for arg in &self.config.extra_args {
cmd.arg(arg);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd
}
fn build_component_add_command(&self) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("rustup");
cmd.args(["component", "add"]);
for component in &self.config.components {
cmd.arg(component);
}
if let Some(ref tc) = self.config.toolchain {
cmd.args(["--toolchain", tc]);
}
for arg in &self.config.extra_args {
cmd.arg(arg);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd
}
fn build_target_add_command(&self) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("rustup");
cmd.args(["target", "add"]);
for target in &self.config.targets {
cmd.arg(target);
}
if let Some(ref tc) = self.config.toolchain {
cmd.args(["--toolchain", tc]);
}
for arg in &self.config.extra_args {
cmd.arg(arg);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd
}
}
#[async_trait]
impl StepBody for RustupStep {
async fn run(&mut self, context: &StepExecutionContext<'_>) -> wfe_core::Result<ExecutionResult> {
let step_name = context.step.name.as_deref().unwrap_or("unknown");
let subcmd = self.config.command.as_str();
tracing::info!(step = step_name, command = subcmd, "running rustup");
let mut cmd = self.build_command();
let output = if let Some(timeout_ms) = self.config.timeout_ms {
let duration = std::time::Duration::from_millis(timeout_ms);
match tokio::time::timeout(duration, cmd.output()).await {
Ok(result) => result.map_err(|e| {
WfeError::StepExecution(format!("Failed to spawn rustup {subcmd}: {e}"))
})?,
Err(_) => {
return Err(WfeError::StepExecution(format!(
"rustup {subcmd} timed out after {timeout_ms}ms"
)));
}
}
} else {
cmd.output()
.await
.map_err(|e| WfeError::StepExecution(format!("Failed to spawn rustup {subcmd}: {e}")))?
};
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
let code = output.status.code().unwrap_or(-1);
return Err(WfeError::StepExecution(format!(
"rustup {subcmd} exited with code {code}\nstdout: {stdout}\nstderr: {stderr}"
)));
}
let mut outputs = serde_json::Map::new();
outputs.insert(
format!("{step_name}.stdout"),
serde_json::Value::String(stdout),
);
outputs.insert(
format!("{step_name}.stderr"),
serde_json::Value::String(stderr),
);
Ok(ExecutionResult {
proceed: true,
output_data: Some(serde_json::Value::Object(outputs)),
..Default::default()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn install_config() -> RustupConfig {
RustupConfig {
command: RustupCommand::Install,
toolchain: None,
components: vec![],
targets: vec![],
profile: None,
default_toolchain: None,
extra_args: vec![],
timeout_ms: None,
}
}
#[test]
fn build_install_command_minimal() {
let step = RustupStep::new(install_config());
let cmd = step.build_command();
let prog = cmd.as_std().get_program().to_str().unwrap();
assert_eq!(prog, "sh");
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args[0], "-c");
assert!(args[1].contains("rustup.rs"));
assert!(args[1].contains("-y"));
}
#[test]
fn build_install_command_with_profile_and_toolchain() {
let mut config = install_config();
config.profile = Some("minimal".to_string());
config.default_toolchain = Some("nightly".to_string());
let step = RustupStep::new(config);
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert!(args[1].contains("--profile minimal"));
assert!(args[1].contains("--default-toolchain nightly"));
}
#[test]
fn build_install_command_with_extra_args() {
let mut config = install_config();
config.extra_args = vec!["--no-modify-path".to_string()];
let step = RustupStep::new(config);
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert!(args[1].contains("--no-modify-path"));
}
#[test]
fn build_toolchain_install_command() {
let config = RustupConfig {
command: RustupCommand::ToolchainInstall,
toolchain: Some("nightly-2024-06-01".to_string()),
components: vec![],
targets: vec![],
profile: Some("minimal".to_string()),
default_toolchain: None,
extra_args: vec![],
timeout_ms: None,
};
let step = RustupStep::new(config);
let cmd = step.build_command();
let prog = cmd.as_std().get_program().to_str().unwrap();
assert_eq!(prog, "rustup");
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["toolchain", "install", "nightly-2024-06-01", "--profile", "minimal"]);
}
#[test]
fn build_toolchain_install_with_extra_args() {
let config = RustupConfig {
command: RustupCommand::ToolchainInstall,
toolchain: Some("stable".to_string()),
components: vec![],
targets: vec![],
profile: None,
default_toolchain: None,
extra_args: vec!["--force".to_string()],
timeout_ms: None,
};
let step = RustupStep::new(config);
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["toolchain", "install", "stable", "--force"]);
}
#[test]
fn build_component_add_command() {
let config = RustupConfig {
command: RustupCommand::ComponentAdd,
toolchain: Some("nightly".to_string()),
components: vec!["clippy".to_string(), "rustfmt".to_string()],
targets: vec![],
profile: None,
default_toolchain: None,
extra_args: vec![],
timeout_ms: None,
};
let step = RustupStep::new(config);
let cmd = step.build_command();
let prog = cmd.as_std().get_program().to_str().unwrap();
assert_eq!(prog, "rustup");
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["component", "add", "clippy", "rustfmt", "--toolchain", "nightly"]);
}
#[test]
fn build_component_add_without_toolchain() {
let config = RustupConfig {
command: RustupCommand::ComponentAdd,
toolchain: None,
components: vec!["rust-src".to_string()],
targets: vec![],
profile: None,
default_toolchain: None,
extra_args: vec![],
timeout_ms: None,
};
let step = RustupStep::new(config);
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["component", "add", "rust-src"]);
}
#[test]
fn build_target_add_command() {
let config = RustupConfig {
command: RustupCommand::TargetAdd,
toolchain: Some("stable".to_string()),
components: vec![],
targets: vec!["wasm32-unknown-unknown".to_string()],
profile: None,
default_toolchain: None,
extra_args: vec![],
timeout_ms: None,
};
let step = RustupStep::new(config);
let cmd = step.build_command();
let prog = cmd.as_std().get_program().to_str().unwrap();
assert_eq!(prog, "rustup");
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["target", "add", "wasm32-unknown-unknown", "--toolchain", "stable"]);
}
#[test]
fn build_target_add_multiple_targets() {
let config = RustupConfig {
command: RustupCommand::TargetAdd,
toolchain: None,
components: vec![],
targets: vec![
"wasm32-unknown-unknown".to_string(),
"aarch64-linux-android".to_string(),
],
profile: None,
default_toolchain: None,
extra_args: vec![],
timeout_ms: None,
};
let step = RustupStep::new(config);
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["target", "add", "wasm32-unknown-unknown", "aarch64-linux-android"]);
}
#[test]
fn build_target_add_with_extra_args() {
let config = RustupConfig {
command: RustupCommand::TargetAdd,
toolchain: Some("nightly".to_string()),
components: vec![],
targets: vec!["x86_64-unknown-linux-musl".to_string()],
profile: None,
default_toolchain: None,
extra_args: vec!["--force".to_string()],
timeout_ms: None,
};
let step = RustupStep::new(config);
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(
args,
vec!["target", "add", "x86_64-unknown-linux-musl", "--toolchain", "nightly", "--force"]
);
}
}