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

22
wfe-rustlang/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "wfe-rustlang"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
description = "Rust toolchain step executors (cargo, rustup) for WFE"
[dependencies]
wfe-core = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
rustdoc-types = "0.38"
[dev-dependencies]
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["test-util", "process"] }
tempfile = { workspace = true }

View File

@@ -0,0 +1,301 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Which cargo subcommand to run.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum CargoCommand {
Build,
Test,
Check,
Clippy,
Fmt,
Doc,
Publish,
Audit,
Deny,
Nextest,
LlvmCov,
DocMdx,
}
impl CargoCommand {
pub fn as_str(&self) -> &'static str {
match self {
Self::Build => "build",
Self::Test => "test",
Self::Check => "check",
Self::Clippy => "clippy",
Self::Fmt => "fmt",
Self::Doc => "doc",
Self::Publish => "publish",
Self::Audit => "audit",
Self::Deny => "deny",
Self::Nextest => "nextest",
Self::LlvmCov => "llvm-cov",
Self::DocMdx => "doc-mdx",
}
}
/// Returns the subcommand arg(s) to pass to cargo.
/// Most commands are a single arg, but nextest needs "nextest run".
/// DocMdx uses `rustdoc` (the actual cargo subcommand).
pub fn subcommand_args(&self) -> Vec<&'static str> {
match self {
Self::Nextest => vec!["nextest", "run"],
Self::DocMdx => vec!["rustdoc"],
other => vec![other.as_str()],
}
}
/// Returns the cargo-install package name if this is an external tool.
/// Returns `None` for built-in cargo subcommands.
pub fn install_package(&self) -> Option<&'static str> {
match self {
Self::Audit => Some("cargo-audit"),
Self::Deny => Some("cargo-deny"),
Self::Nextest => Some("cargo-nextest"),
Self::LlvmCov => Some("cargo-llvm-cov"),
_ => None,
}
}
/// Returns the binary name to probe for availability.
pub fn binary_name(&self) -> Option<&'static str> {
match self {
Self::Audit => Some("cargo-audit"),
Self::Deny => Some("cargo-deny"),
Self::Nextest => Some("cargo-nextest"),
Self::LlvmCov => Some("cargo-llvm-cov"),
_ => None,
}
}
}
/// Shared configuration for all cargo step types.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CargoConfig {
pub command: CargoCommand,
/// Rust toolchain override (e.g. "nightly", "1.78.0").
#[serde(default)]
pub toolchain: Option<String>,
/// Target package (`-p`).
#[serde(default)]
pub package: Option<String>,
/// Features to enable (`--features`).
#[serde(default)]
pub features: Vec<String>,
/// Enable all features (`--all-features`).
#[serde(default)]
pub all_features: bool,
/// Disable default features (`--no-default-features`).
#[serde(default)]
pub no_default_features: bool,
/// Build in release mode (`--release`).
#[serde(default)]
pub release: bool,
/// Compilation target triple (`--target`).
#[serde(default)]
pub target: Option<String>,
/// Build profile (`--profile`).
#[serde(default)]
pub profile: Option<String>,
/// Additional arguments appended to the command.
#[serde(default)]
pub extra_args: Vec<String>,
/// Environment variables.
#[serde(default)]
pub env: HashMap<String, String>,
/// Working directory.
#[serde(default)]
pub working_dir: Option<String>,
/// Execution timeout in milliseconds.
#[serde(default)]
pub timeout_ms: Option<u64>,
/// Output directory for generated files (e.g., MDX docs).
#[serde(default)]
pub output_dir: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn serde_round_trip_minimal() {
let config = CargoConfig {
command: CargoCommand::Build,
toolchain: None,
package: None,
features: vec![],
all_features: false,
no_default_features: false,
release: false,
target: None,
profile: None,
extra_args: vec![],
env: HashMap::new(),
working_dir: None,
timeout_ms: None,
output_dir: None,
};
let json = serde_json::to_string(&config).unwrap();
let de: CargoConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.command, CargoCommand::Build);
assert!(de.features.is_empty());
assert!(!de.release);
}
#[test]
fn serde_round_trip_full() {
let mut env = HashMap::new();
env.insert("RUSTFLAGS".to_string(), "-D warnings".to_string());
let config = CargoConfig {
command: CargoCommand::Clippy,
toolchain: Some("nightly".to_string()),
package: Some("my-crate".to_string()),
features: vec!["feat1".to_string(), "feat2".to_string()],
all_features: false,
no_default_features: true,
release: true,
target: Some("x86_64-unknown-linux-gnu".to_string()),
profile: None,
extra_args: vec!["--".to_string(), "-D".to_string(), "warnings".to_string()],
env,
working_dir: Some("/src".to_string()),
timeout_ms: Some(60_000),
output_dir: None,
};
let json = serde_json::to_string(&config).unwrap();
let de: CargoConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.command, CargoCommand::Clippy);
assert_eq!(de.toolchain, Some("nightly".to_string()));
assert_eq!(de.package, Some("my-crate".to_string()));
assert_eq!(de.features, vec!["feat1", "feat2"]);
assert!(de.no_default_features);
assert!(de.release);
assert_eq!(de.extra_args, vec!["--", "-D", "warnings"]);
assert_eq!(de.timeout_ms, Some(60_000));
}
#[test]
fn command_as_str() {
assert_eq!(CargoCommand::Build.as_str(), "build");
assert_eq!(CargoCommand::Test.as_str(), "test");
assert_eq!(CargoCommand::Check.as_str(), "check");
assert_eq!(CargoCommand::Clippy.as_str(), "clippy");
assert_eq!(CargoCommand::Fmt.as_str(), "fmt");
assert_eq!(CargoCommand::Doc.as_str(), "doc");
assert_eq!(CargoCommand::Publish.as_str(), "publish");
assert_eq!(CargoCommand::Audit.as_str(), "audit");
assert_eq!(CargoCommand::Deny.as_str(), "deny");
assert_eq!(CargoCommand::Nextest.as_str(), "nextest");
assert_eq!(CargoCommand::LlvmCov.as_str(), "llvm-cov");
assert_eq!(CargoCommand::DocMdx.as_str(), "doc-mdx");
}
#[test]
fn command_serde_kebab_case() {
let json = r#""build""#;
let cmd: CargoCommand = serde_json::from_str(json).unwrap();
assert_eq!(cmd, CargoCommand::Build);
let serialized = serde_json::to_string(&CargoCommand::Build).unwrap();
assert_eq!(serialized, r#""build""#);
// External tools
let json = r#""llvm-cov""#;
let cmd: CargoCommand = serde_json::from_str(json).unwrap();
assert_eq!(cmd, CargoCommand::LlvmCov);
let json = r#""nextest""#;
let cmd: CargoCommand = serde_json::from_str(json).unwrap();
assert_eq!(cmd, CargoCommand::Nextest);
let json = r#""doc-mdx""#;
let cmd: CargoCommand = serde_json::from_str(json).unwrap();
assert_eq!(cmd, CargoCommand::DocMdx);
}
#[test]
fn subcommand_args_single() {
assert_eq!(CargoCommand::Build.subcommand_args(), vec!["build"]);
assert_eq!(CargoCommand::Audit.subcommand_args(), vec!["audit"]);
assert_eq!(CargoCommand::LlvmCov.subcommand_args(), vec!["llvm-cov"]);
}
#[test]
fn subcommand_args_nextest_has_run() {
assert_eq!(CargoCommand::Nextest.subcommand_args(), vec!["nextest", "run"]);
}
#[test]
fn subcommand_args_doc_mdx_uses_rustdoc() {
assert_eq!(CargoCommand::DocMdx.subcommand_args(), vec!["rustdoc"]);
}
#[test]
fn install_package_external_tools() {
assert_eq!(CargoCommand::Audit.install_package(), Some("cargo-audit"));
assert_eq!(CargoCommand::Deny.install_package(), Some("cargo-deny"));
assert_eq!(CargoCommand::Nextest.install_package(), Some("cargo-nextest"));
assert_eq!(CargoCommand::LlvmCov.install_package(), Some("cargo-llvm-cov"));
}
#[test]
fn install_package_builtin_returns_none() {
assert_eq!(CargoCommand::Build.install_package(), None);
assert_eq!(CargoCommand::Test.install_package(), None);
assert_eq!(CargoCommand::Check.install_package(), None);
assert_eq!(CargoCommand::Clippy.install_package(), None);
assert_eq!(CargoCommand::Fmt.install_package(), None);
assert_eq!(CargoCommand::Doc.install_package(), None);
assert_eq!(CargoCommand::Publish.install_package(), None);
assert_eq!(CargoCommand::DocMdx.install_package(), None);
}
#[test]
fn binary_name_external_tools() {
assert_eq!(CargoCommand::Audit.binary_name(), Some("cargo-audit"));
assert_eq!(CargoCommand::Deny.binary_name(), Some("cargo-deny"));
assert_eq!(CargoCommand::Nextest.binary_name(), Some("cargo-nextest"));
assert_eq!(CargoCommand::LlvmCov.binary_name(), Some("cargo-llvm-cov"));
}
#[test]
fn binary_name_builtin_returns_none() {
assert_eq!(CargoCommand::Build.binary_name(), None);
assert_eq!(CargoCommand::Test.binary_name(), None);
}
#[test]
fn config_defaults() {
let json = r#"{"command": "test"}"#;
let config: CargoConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.command, CargoCommand::Test);
assert!(config.toolchain.is_none());
assert!(config.package.is_none());
assert!(config.features.is_empty());
assert!(!config.all_features);
assert!(!config.no_default_features);
assert!(!config.release);
assert!(config.target.is_none());
assert!(config.profile.is_none());
assert!(config.extra_args.is_empty());
assert!(config.env.is_empty());
assert!(config.working_dir.is_none());
assert!(config.timeout_ms.is_none());
assert!(config.output_dir.is_none());
}
#[test]
fn config_with_output_dir() {
let json = r#"{"command": "doc-mdx", "output_dir": "docs/api"}"#;
let config: CargoConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.command, CargoCommand::DocMdx);
assert_eq!(config.output_dir, Some("docs/api".to_string()));
}
}

View File

@@ -0,0 +1,5 @@
pub mod config;
pub mod step;
pub use config::{CargoCommand, CargoConfig};
pub use step::CargoStep;

View File

@@ -0,0 +1,532 @@
use async_trait::async_trait;
use wfe_core::models::ExecutionResult;
use wfe_core::traits::step::{StepBody, StepExecutionContext};
use wfe_core::WfeError;
use crate::cargo::config::{CargoCommand, CargoConfig};
pub struct CargoStep {
config: CargoConfig,
}
impl CargoStep {
pub fn new(config: CargoConfig) -> Self {
Self { config }
}
pub fn build_command(&self) -> tokio::process::Command {
// DocMdx requires nightly for --output-format json.
let toolchain = if matches!(self.config.command, CargoCommand::DocMdx) {
Some(self.config.toolchain.as_deref().unwrap_or("nightly"))
} else {
self.config.toolchain.as_deref()
};
let mut cmd = if let Some(tc) = toolchain {
let mut c = tokio::process::Command::new("rustup");
c.args(["run", tc, "cargo"]);
c
} else {
tokio::process::Command::new("cargo")
};
for arg in self.config.command.subcommand_args() {
cmd.arg(arg);
}
if let Some(ref pkg) = self.config.package {
cmd.args(["-p", pkg]);
}
if !self.config.features.is_empty() {
cmd.args(["--features", &self.config.features.join(",")]);
}
if self.config.all_features {
cmd.arg("--all-features");
}
if self.config.no_default_features {
cmd.arg("--no-default-features");
}
if self.config.release {
cmd.arg("--release");
}
if let Some(ref target) = self.config.target {
cmd.args(["--target", target]);
}
if let Some(ref profile) = self.config.profile {
cmd.args(["--profile", profile]);
}
for arg in &self.config.extra_args {
cmd.arg(arg);
}
// DocMdx appends rustdoc-specific flags after user extra_args.
if matches!(self.config.command, CargoCommand::DocMdx) {
cmd.args(["--", "-Z", "unstable-options", "--output-format", "json"]);
}
for (key, value) in &self.config.env {
cmd.env(key, value);
}
if let Some(ref dir) = self.config.working_dir {
cmd.current_dir(dir);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd
}
/// Ensures an external cargo tool is installed before running it.
/// For built-in cargo subcommands, this is a no-op.
async fn ensure_tool_available(&self) -> Result<(), WfeError> {
let (binary, package) = match (self.config.command.binary_name(), self.config.command.install_package()) {
(Some(b), Some(p)) => (b, p),
_ => return Ok(()),
};
// Probe for the binary.
let probe = tokio::process::Command::new(binary)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await;
if let Ok(status) = probe {
if status.success() {
return Ok(());
}
}
tracing::info!(package = package, "cargo tool not found, installing");
// For llvm-cov, ensure the rustup component is present first.
if matches!(self.config.command, CargoCommand::LlvmCov) {
let component = tokio::process::Command::new("rustup")
.args(["component", "add", "llvm-tools-preview"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await
.map_err(|e| WfeError::StepExecution(format!(
"Failed to add llvm-tools-preview component: {e}"
)))?;
if !component.status.success() {
let stderr = String::from_utf8_lossy(&component.stderr);
return Err(WfeError::StepExecution(format!(
"Failed to add llvm-tools-preview component: {stderr}"
)));
}
}
let install = tokio::process::Command::new("cargo")
.args(["install", package])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await
.map_err(|e| WfeError::StepExecution(format!(
"Failed to install {package}: {e}"
)))?;
if !install.status.success() {
let stderr = String::from_utf8_lossy(&install.stderr);
return Err(WfeError::StepExecution(format!(
"Failed to install {package}: {stderr}"
)));
}
tracing::info!(package = package, "cargo tool installed successfully");
Ok(())
}
/// Post-process rustdoc JSON output into MDX files.
fn transform_rustdoc_json(
&self,
outputs: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<(), WfeError> {
use crate::rustdoc::transformer::{transform_to_mdx, write_mdx_files};
// Find the JSON file in target/doc/.
let working_dir = self.config.working_dir.as_deref().unwrap_or(".");
let doc_dir = std::path::Path::new(working_dir).join("target/doc");
let json_path = std::fs::read_dir(&doc_dir)
.map_err(|e| WfeError::StepExecution(format!(
"failed to read target/doc: {e}"
)))?
.filter_map(|entry| entry.ok())
.find(|entry| {
entry.path().extension().is_some_and(|ext| ext == "json")
})
.map(|entry| entry.path())
.ok_or_else(|| WfeError::StepExecution(
"no JSON file found in target/doc/ — did rustdoc --output-format json succeed?".to_string()
))?;
tracing::info!(path = %json_path.display(), "reading rustdoc JSON");
let json_content = std::fs::read_to_string(&json_path).map_err(|e| {
WfeError::StepExecution(format!("failed to read {}: {e}", json_path.display()))
})?;
let krate: rustdoc_types::Crate = serde_json::from_str(&json_content).map_err(|e| {
WfeError::StepExecution(format!("failed to parse rustdoc JSON: {e}"))
})?;
let mdx_files = transform_to_mdx(&krate);
let output_dir = self.config.output_dir
.as_deref()
.unwrap_or("target/doc/mdx");
let output_path = std::path::Path::new(working_dir).join(output_dir);
write_mdx_files(&mdx_files, &output_path).map_err(|e| {
WfeError::StepExecution(format!("failed to write MDX files: {e}"))
})?;
let file_count = mdx_files.len();
tracing::info!(
output_dir = %output_path.display(),
file_count,
"generated MDX documentation"
);
outputs.insert(
"mdx.output_dir".to_string(),
serde_json::Value::String(output_path.to_string_lossy().to_string()),
);
outputs.insert(
"mdx.file_count".to_string(),
serde_json::Value::Number(file_count.into()),
);
let file_paths: Vec<_> = mdx_files.iter().map(|f| f.path.clone()).collect();
outputs.insert(
"mdx.files".to_string(),
serde_json::Value::Array(
file_paths.into_iter().map(serde_json::Value::String).collect(),
),
);
Ok(())
}
}
#[async_trait]
impl StepBody for CargoStep {
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();
// Ensure external tools are installed before running.
self.ensure_tool_available().await?;
tracing::info!(step = step_name, command = subcmd, "running cargo");
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 cargo {subcmd}: {e}"))
})?,
Err(_) => {
return Err(WfeError::StepExecution(format!(
"cargo {subcmd} timed out after {timeout_ms}ms"
)));
}
}
} else {
cmd.output()
.await
.map_err(|e| WfeError::StepExecution(format!("Failed to spawn cargo {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!(
"cargo {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),
);
// DocMdx post-processing: transform rustdoc JSON → MDX files.
if matches!(self.config.command, CargoCommand::DocMdx) {
self.transform_rustdoc_json(&mut outputs)?;
}
Ok(ExecutionResult {
proceed: true,
output_data: Some(serde_json::Value::Object(outputs)),
..Default::default()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cargo::config::{CargoCommand, CargoConfig};
use std::collections::HashMap;
fn minimal_config(command: CargoCommand) -> CargoConfig {
CargoConfig {
command,
toolchain: None,
package: None,
features: vec![],
all_features: false,
no_default_features: false,
release: false,
target: None,
profile: None,
extra_args: vec![],
env: HashMap::new(),
working_dir: None,
timeout_ms: None,
output_dir: None,
}
}
#[test]
fn build_command_minimal() {
let step = CargoStep::new(minimal_config(CargoCommand::Build));
let cmd = step.build_command();
let prog = cmd.as_std().get_program().to_str().unwrap();
assert_eq!(prog, "cargo");
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["build"]);
}
#[test]
fn build_command_with_toolchain() {
let mut config = minimal_config(CargoCommand::Test);
config.toolchain = Some("nightly".to_string());
let step = CargoStep::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!["run", "nightly", "cargo", "test"]);
}
#[test]
fn build_command_with_package_and_features() {
let mut config = minimal_config(CargoCommand::Check);
config.package = Some("my-crate".to_string());
config.features = vec!["feat1".to_string(), "feat2".to_string()];
let step = CargoStep::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!["check", "-p", "my-crate", "--features", "feat1,feat2"]);
}
#[test]
fn build_command_release_and_target() {
let mut config = minimal_config(CargoCommand::Build);
config.release = true;
config.target = Some("aarch64-unknown-linux-gnu".to_string());
let step = CargoStep::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!["build", "--release", "--target", "aarch64-unknown-linux-gnu"]);
}
#[test]
fn build_command_all_flags() {
let mut config = minimal_config(CargoCommand::Clippy);
config.all_features = true;
config.no_default_features = true;
config.profile = Some("dev".to_string());
config.extra_args = vec!["--".to_string(), "-D".to_string(), "warnings".to_string()];
let step = CargoStep::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!["clippy", "--all-features", "--no-default-features", "--profile", "dev", "--", "-D", "warnings"]
);
}
#[test]
fn build_command_fmt() {
let mut config = minimal_config(CargoCommand::Fmt);
config.extra_args = vec!["--check".to_string()];
let step = CargoStep::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!["fmt", "--check"]);
}
#[test]
fn build_command_publish_dry_run() {
let mut config = minimal_config(CargoCommand::Publish);
config.extra_args = vec!["--dry-run".to_string(), "--registry".to_string(), "my-reg".to_string()];
let step = CargoStep::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!["publish", "--dry-run", "--registry", "my-reg"]);
}
#[test]
fn build_command_doc() {
let mut config = minimal_config(CargoCommand::Doc);
config.extra_args = vec!["--no-deps".to_string()];
config.release = true;
let step = CargoStep::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!["doc", "--release", "--no-deps"]);
}
#[test]
fn build_command_env_vars() {
let mut config = minimal_config(CargoCommand::Build);
config.env.insert("RUSTFLAGS".to_string(), "-D warnings".to_string());
let step = CargoStep::new(config);
let cmd = step.build_command();
let envs: Vec<_> = cmd.as_std().get_envs().collect();
assert!(envs.iter().any(|(k, v)| *k == "RUSTFLAGS" && v == &Some("-D warnings".as_ref())));
}
#[test]
fn build_command_working_dir() {
let mut config = minimal_config(CargoCommand::Test);
config.working_dir = Some("/my/project".to_string());
let step = CargoStep::new(config);
let cmd = step.build_command();
assert_eq!(cmd.as_std().get_current_dir(), Some(std::path::Path::new("/my/project")));
}
#[test]
fn build_command_audit() {
let step = CargoStep::new(minimal_config(CargoCommand::Audit));
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["audit"]);
}
#[test]
fn build_command_deny() {
let mut config = minimal_config(CargoCommand::Deny);
config.extra_args = vec!["check".to_string()];
let step = CargoStep::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!["deny", "check"]);
}
#[test]
fn build_command_nextest() {
let step = CargoStep::new(minimal_config(CargoCommand::Nextest));
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["nextest", "run"]);
}
#[test]
fn build_command_nextest_with_features() {
let mut config = minimal_config(CargoCommand::Nextest);
config.features = vec!["feat1".to_string()];
config.extra_args = vec!["--no-fail-fast".to_string()];
let step = CargoStep::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!["nextest", "run", "--features", "feat1", "--no-fail-fast"]);
}
#[test]
fn build_command_llvm_cov() {
let step = CargoStep::new(minimal_config(CargoCommand::LlvmCov));
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args, vec!["llvm-cov"]);
}
#[test]
fn build_command_llvm_cov_with_args() {
let mut config = minimal_config(CargoCommand::LlvmCov);
config.extra_args = vec!["--html".to_string(), "--output-dir".to_string(), "coverage".to_string()];
let step = CargoStep::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!["llvm-cov", "--html", "--output-dir", "coverage"]);
}
#[test]
fn build_command_doc_mdx_forces_nightly() {
let step = CargoStep::new(minimal_config(CargoCommand::DocMdx));
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!["run", "nightly", "cargo", "rustdoc", "--", "-Z", "unstable-options", "--output-format", "json"]
);
}
#[test]
fn build_command_doc_mdx_with_package() {
let mut config = minimal_config(CargoCommand::DocMdx);
config.package = Some("my-crate".to_string());
config.extra_args = vec!["--no-deps".to_string()];
let step = CargoStep::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!["run", "nightly", "cargo", "rustdoc", "-p", "my-crate", "--no-deps", "--", "-Z", "unstable-options", "--output-format", "json"]
);
}
#[test]
fn build_command_doc_mdx_custom_toolchain() {
let mut config = minimal_config(CargoCommand::DocMdx);
config.toolchain = Some("nightly-2024-06-01".to_string());
let step = CargoStep::new(config);
let cmd = step.build_command();
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
assert!(args.contains(&"nightly-2024-06-01"));
}
#[tokio::test]
async fn ensure_tool_builtin_is_noop() {
let step = CargoStep::new(minimal_config(CargoCommand::Build));
// Should return Ok immediately for built-in commands.
step.ensure_tool_available().await.unwrap();
}
#[tokio::test]
async fn ensure_tool_already_installed_succeeds() {
// cargo-audit/nextest/etc may or may not be installed,
// but we can test with a known-installed tool: cargo itself
// is always available. Test the flow by verifying the
// built-in path returns Ok.
let step = CargoStep::new(minimal_config(CargoCommand::Check));
step.ensure_tool_available().await.unwrap();
}
}

6
wfe-rustlang/src/lib.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod cargo;
pub mod rustdoc;
pub mod rustup;
pub use cargo::{CargoCommand, CargoConfig, CargoStep};
pub use rustup::{RustupCommand, RustupConfig, RustupStep};

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"]
);
}
}