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:
22
wfe-rustlang/Cargo.toml
Normal file
22
wfe-rustlang/Cargo.toml
Normal 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 }
|
||||
301
wfe-rustlang/src/cargo/config.rs
Normal file
301
wfe-rustlang/src/cargo/config.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
5
wfe-rustlang/src/cargo/mod.rs
Normal file
5
wfe-rustlang/src/cargo/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod step;
|
||||
|
||||
pub use config::{CargoCommand, CargoConfig};
|
||||
pub use step::CargoStep;
|
||||
532
wfe-rustlang/src/cargo/step.rs
Normal file
532
wfe-rustlang/src/cargo/step.rs
Normal 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
6
wfe-rustlang/src/lib.rs
Normal 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};
|
||||
183
wfe-rustlang/src/rustup/config.rs
Normal file
183
wfe-rustlang/src/rustup/config.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
5
wfe-rustlang/src/rustup/mod.rs
Normal file
5
wfe-rustlang/src/rustup/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod step;
|
||||
|
||||
pub use config::{RustupCommand, RustupConfig};
|
||||
pub use step::RustupStep;
|
||||
357
wfe-rustlang/src/rustup/step.rs
Normal file
357
wfe-rustlang/src/rustup/step.rs
Normal 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user