use crate::error::DependencyError; use crate::types::{DeltaExecutionContext, Engine, EventSender, ExecutionContext}; use crate::upm::UPMSysDeps; use async_trait::async_trait; use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; use super::UserPackageManager; /// Default manifest paths used when `manifests` is not specified /// in the `[rust.cargo]` sub-config. /// /// The bootstrap stage creates `/workspace` as the standard project /// root. Most Docker-based builds have a single `Cargo.toml` here. fn default_manifests() -> Vec { vec![PathBuf::from("/workspace/Cargo.toml")] } #[derive(Clone)] pub struct Rust; // ── RustConfig (root) ───────────────────────────────────────────── /// Top-level configuration for the `rust` UPM. /// /// ```yaml /// rust: /// toolchain: "nightly-2024-01-01" /// components: ["clippy", "rustfmt"] /// targets: ["wasm32-unknown-unknown"] /// cargo: /// fetch: true /// locked: true /// manifests: ["/workspace/Cargo.toml"] /// packages: ["cargo-audit"] /// ``` #[derive(Debug, Clone, Deserialize)] struct RustConfig { #[allow(dead_code)] /// UPM backend type (currently only `"rustup"`). #[serde(default = "default_rust_type", rename = "type")] type_: String, /// Environment variables injected into every rustup/cargo child process. /// Each entry maps to a var name; `None` unsets the variable. #[serde(default, rename = "env")] env_: HashMap>, /// Rust toolchain to activate via `rustup default`. /// If unset, the existing default toolchain is left alone. #[serde(default)] toolchain: Option, /// Rustup components to install (e.g. `clippy`, `rustfmt`). #[serde(default)] components: Vec, /// Compilation targets for cross-compilation. /// Each entry runs `rustup target add `. #[serde(default)] targets: Vec, /// Cargo sub-configuration (fetch, install, manifests, …). /// Optional — omit if you only need rustup-level setup. #[serde(default)] cargo: CargoSubConfig, } fn default_rust_type() -> String { "rustup".to_string() } // ── Cargo sub-config ─────────────────────────────────────────────── /// Nested cargo configuration inside `rust`. #[derive(Debug, Clone, Deserialize, Default)] struct CargoSubConfig { /// Crate names to install via `cargo install`. #[serde(default)] packages: Vec, /// Alternative registry URL (passed as `--registry`). #[serde(default)] registry: Option, /// Additional flags forwarded to `cargo install` /// (e.g. `--force`, `--version 0.1.0`). #[serde(default)] args: Vec, /// One or more `Cargo.toml` paths for `cargo fetch`. /// Each path spawns a separate `cargo fetch --manifest-path `. #[serde(default = "default_manifests")] manifests: Vec, /// When `true`, run `cargo fetch` before installing packages. #[serde(default)] fetch: bool, /// Require an up-to-date `Cargo.lock` (passes `--locked`). #[serde(default)] locked: bool, } // ── UserPackageManager impl ──────────────────────────────────────── #[async_trait] impl UserPackageManager for Rust { fn name(&self) -> &'static str { "rust" } fn system_dependency(&self, _config: &serde_yaml::Value) -> UPMSysDeps { UPMSysDeps::new(vec!["rustup".to_string()], false) } async fn main( &self, config: &serde_yaml::Value, ctx: &ExecutionContext, event_tx: &EventSender, ) -> Result<(), DependencyError> { let cfg: RustConfig = serde_yaml::from_value(config.clone()).map_err(|e| { log::error!("Failed to parse user.rust config: {}", e); DependencyError::ParseError() })?; let engine = Engine::new(); let mut commands: Vec = Vec::new(); // ── 1. rustup default ───────────────────────── if let Some(ref tc) = cfg.toolchain { commands.push(format!("rustup default '{}'", tc)); } // ── 2. rustup component add ────────────────────── if !cfg.components.is_empty() { commands.push(format!("rustup component add {}", cfg.components.join(" "))); } // ── 3. rustup target add ─────────────────────── if !cfg.targets.is_empty() { commands.push(format!("rustup target add {}", cfg.targets.join(" "))); } // ── 4. cargo fetch ─────────────────────────────────────── // // `cargo fetch` without --target only fetches host-target // dependencies. For cross-compilation we must fetch once // per (manifest, target) pair. let cargo = &cfg.cargo; if cargo.fetch { let mut base = String::from("cargo fetch"); if cargo.locked { base.push_str(" --locked"); } for manifest in &cargo.manifests { // Host target (no --target) commands.push(format!("{} --manifest-path '{}'", base, manifest.display())); // Cross-compilation targets for target in &cfg.targets { commands.push(format!( "{} --target '{}' --manifest-path '{}'", base, target, manifest.display() )); } } } // ── 5. cargo install ───────────────────────────────────── if !cargo.packages.is_empty() { let mut cmd = String::from("cargo install"); if cargo.locked { cmd.push_str(" --locked"); } if let Some(ref registry) = cargo.registry { cmd.push_str(&format!(" --registry '{}'", registry)); } for a in &cargo.args { cmd.push(' '); cmd.push_str(a); } for pkg in &cargo.packages { cmd.push(' '); cmd.push_str(pkg); } commands.push(cmd); } // ── Execute ────────────────────────────────────────────── for cmd in &commands { log::info!("Rust UPM executing: {}", cmd); let delta = DeltaExecutionContext { env_vars: cfg.env_.clone(), ..Default::default() }; let result = engine .execute_command_with_delta(cmd, ctx, event_tx, &delta) .await; if let Err(e) = result { log::error!("Rust command failed: {}", e); return Err(DependencyError::CustomFailed(e)); } } if commands.is_empty() { log::warn!("Rust UPM configured with nothing to do"); } Ok(()) } } // ── Tests ────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn test_name() { assert_eq!(Rust.name(), "rust"); } #[test] fn test_system_dependency() { let deps = Rust.system_dependency(&serde_yaml::Value::Null); assert_eq!(deps.packages, vec!["rustup"]); assert!(!deps.mapped); } #[test] fn test_parse_empty() { let yaml = "{}"; let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap(); assert!(cfg.toolchain.is_none()); assert!(cfg.components.is_empty()); assert!(cfg.targets.is_empty()); assert!(!cfg.cargo.fetch); } #[test] fn test_parse_rustup_only() { let yaml = r#" toolchain: "nightly-2024-01-01" components: ["clippy", "rustfmt"] targets: ["wasm32-unknown-unknown", "x86_64-unknown-linux-musl"] "#; let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(cfg.toolchain.as_deref(), Some("nightly-2024-01-01")); assert_eq!(cfg.components, vec!["clippy", "rustfmt"]); assert_eq!( cfg.targets, vec!["wasm32-unknown-unknown", "x86_64-unknown-linux-musl"] ); assert!(!cfg.cargo.fetch); } #[test] fn test_parse_cargo_subconfig() { let yaml = r#" components: ["clippy"] cargo: fetch: true locked: true manifests: - "/workspace/foo/Cargo.toml" - "/workspace/bar/Cargo.toml" packages: ["cargo-audit"] registry: "https://mirrors.tuna.tsinghua.edu.cn/crates.io-index" "#; let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(cfg.components, vec!["clippy"]); let c = &cfg.cargo; assert!(c.fetch); assert!(c.locked); assert_eq!( c.manifests, vec![ PathBuf::from("/workspace/foo/Cargo.toml"), PathBuf::from("/workspace/bar/Cargo.toml"), ] ); assert_eq!(c.packages, vec!["cargo-audit"]); assert_eq!( c.registry.as_deref(), Some("https://mirrors.tuna.tsinghua.edu.cn/crates.io-index") ); } #[test] fn test_cargo_defaults() { let yaml = r#" components: ["rustfmt"] cargo: {} "#; let cfg: RustConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(cfg.components, vec!["rustfmt"]); assert!(!cfg.cargo.fetch); assert!(!cfg.cargo.locked); assert_eq!( cfg.cargo.manifests, vec![PathBuf::from("/workspace/Cargo.toml")] ); } #[test] fn test_default_manifests() { assert_eq!( default_manifests(), vec![PathBuf::from("/workspace/Cargo.toml")] ); } }