diff --git a/wfe-rustlang/Cargo.toml b/wfe-rustlang/Cargo.toml new file mode 100644 index 0000000..0530f22 --- /dev/null +++ b/wfe-rustlang/Cargo.toml @@ -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 } diff --git a/wfe-rustlang/src/cargo/config.rs b/wfe-rustlang/src/cargo/config.rs new file mode 100644 index 0000000..af54540 --- /dev/null +++ b/wfe-rustlang/src/cargo/config.rs @@ -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, + /// Target package (`-p`). + #[serde(default)] + pub package: Option, + /// Features to enable (`--features`). + #[serde(default)] + pub features: Vec, + /// 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, + /// Build profile (`--profile`). + #[serde(default)] + pub profile: Option, + /// Additional arguments appended to the command. + #[serde(default)] + pub extra_args: Vec, + /// Environment variables. + #[serde(default)] + pub env: HashMap, + /// Working directory. + #[serde(default)] + pub working_dir: Option, + /// Execution timeout in milliseconds. + #[serde(default)] + pub timeout_ms: Option, + /// Output directory for generated files (e.g., MDX docs). + #[serde(default)] + pub output_dir: Option, +} + +#[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())); + } +} diff --git a/wfe-rustlang/src/cargo/mod.rs b/wfe-rustlang/src/cargo/mod.rs new file mode 100644 index 0000000..c798981 --- /dev/null +++ b/wfe-rustlang/src/cargo/mod.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod step; + +pub use config::{CargoCommand, CargoConfig}; +pub use step::CargoStep; diff --git a/wfe-rustlang/src/cargo/step.rs b/wfe-rustlang/src/cargo/step.rs new file mode 100644 index 0000000..41a5187 --- /dev/null +++ b/wfe-rustlang/src/cargo/step.rs @@ -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, + ) -> 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 { + 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(); + } +} diff --git a/wfe-rustlang/src/lib.rs b/wfe-rustlang/src/lib.rs new file mode 100644 index 0000000..8e141dc --- /dev/null +++ b/wfe-rustlang/src/lib.rs @@ -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}; diff --git a/wfe-rustlang/src/rustup/config.rs b/wfe-rustlang/src/rustup/config.rs new file mode 100644 index 0000000..b3c80bc --- /dev/null +++ b/wfe-rustlang/src/rustup/config.rs @@ -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, + /// Components to add (e.g. ["clippy", "rustfmt", "rust-src"]). + #[serde(default)] + pub components: Vec, + /// Compilation targets to add (e.g. ["wasm32-unknown-unknown"]). + #[serde(default)] + pub targets: Vec, + /// Rustup profile for initial install: "minimal", "default", or "complete". + #[serde(default)] + pub profile: Option, + /// Default toolchain to set during install. + #[serde(default)] + pub default_toolchain: Option, + /// Additional arguments appended to the command. + #[serde(default)] + pub extra_args: Vec, + /// Execution timeout in milliseconds. + #[serde(default)] + pub timeout_ms: Option, +} + +#[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"]); + } +} diff --git a/wfe-rustlang/src/rustup/mod.rs b/wfe-rustlang/src/rustup/mod.rs new file mode 100644 index 0000000..2eb7406 --- /dev/null +++ b/wfe-rustlang/src/rustup/mod.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod step; + +pub use config::{RustupCommand, RustupConfig}; +pub use step::RustupStep; diff --git a/wfe-rustlang/src/rustup/step.rs b/wfe-rustlang/src/rustup/step.rs new file mode 100644 index 0000000..721f7ca --- /dev/null +++ b/wfe-rustlang/src/rustup/step.rs @@ -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 { + 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"] + ); + } +}