sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

wasm.rs (10367B)


      1 use std::{
      2     collections::BTreeSet,
      3     env, fs,
      4     path::{Path, PathBuf},
      5     process::Command,
      6 };
      7 
      8 use crate::{
      9     check::check_wasm_package_surface,
     10     fs::workspace_root,
     11     package_matrix::{WasmPackageSpec, validate_package_matrix, wasm_package_specs},
     12 };
     13 
     14 const WASM_TARGET: &str = "wasm32-unknown-unknown";
     15 
     16 pub fn generate(args: &[String]) -> Result<(), String> {
     17     validate_package_matrix()?;
     18     let specs = selected_specs(args)?;
     19     let root = workspace_root()?;
     20     let toolchain = resolve_wasm_toolchain()?;
     21     for spec in specs {
     22         let dist_dir = root.join(spec.package_dir).join("dist");
     23         if dist_dir.exists() {
     24             fs::remove_dir_all(&dist_dir)
     25                 .map_err(|error| format!("failed to remove {}: {error}", dist_dir.display()))?;
     26         }
     27         let mut command = Command::new(&toolchain.wasm_pack);
     28         command.current_dir(&root);
     29         for arg in wasm_pack_args(spec) {
     30             command.arg(arg);
     31         }
     32         if let Some(parent) = toolchain.rustc.parent() {
     33             prepend_path(&mut command, parent);
     34         }
     35         command.env("RUSTC", &toolchain.rustc);
     36         command.env("CARGO", &toolchain.cargo);
     37         let status = command.status().map_err(|error| {
     38             format!(
     39                 "failed to start wasm-pack for {} while generating {}: {error}",
     40                 spec.key, spec.package_name
     41             )
     42         })?;
     43         if !status.success() {
     44             return Err(format!(
     45                 "wasm-pack failed for {} while generating {} with status {status}; rerun `cargo xtask generate wasm --package {}` after fixing the wasm toolchain",
     46                 spec.key, spec.package_name, spec.key
     47             ));
     48         }
     49         check_wasm_package_surface(&root, spec)?;
     50         println!("generated wasm package {}", spec.package_name);
     51     }
     52     Ok(())
     53 }
     54 
     55 struct WasmToolchain {
     56     wasm_pack: PathBuf,
     57     rustc: PathBuf,
     58     cargo: PathBuf,
     59 }
     60 
     61 fn resolve_wasm_toolchain() -> Result<WasmToolchain, String> {
     62     let wasm_pack = resolve_required_path_tool("wasm-pack")?;
     63     let rustc = resolve_required_rust_tool("rustc", "RUSTC")?;
     64     let cargo = resolve_required_rust_tool("cargo", "CARGO")?;
     65     ensure_wasm_target_installed()?;
     66     Ok(WasmToolchain {
     67         wasm_pack,
     68         rustc,
     69         cargo,
     70     })
     71 }
     72 
     73 fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> {
     74     vec![
     75         "build",
     76         spec.crate_dir,
     77         "--release",
     78         "--target",
     79         "web",
     80         "--out-dir",
     81         spec.out_dir,
     82         "--out-name",
     83         spec.out_name,
     84         "--no-pack",
     85     ]
     86 }
     87 
     88 fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> {
     89     match args {
     90         [] => Ok(wasm_package_specs().to_vec()),
     91         [flag, key] if flag == "--package" => wasm_package_specs()
     92             .iter()
     93             .copied()
     94             .find(|spec| spec.key == key)
     95             .map(|spec| vec![spec])
     96             .ok_or_else(|| format!("unknown wasm package: {key}")),
     97         _ => Err("usage: cargo xtask generate wasm [--package <key>]".to_owned()),
     98     }
     99 }
    100 
    101 fn resolve_required_path_tool(name: &str) -> Result<PathBuf, String> {
    102     let path = env::var_os("PATH").ok_or_else(|| {
    103         format!("missing {name}: PATH is not set; install {name} and expose it on PATH")
    104     })?;
    105     resolve_path_tool_from_path(name, &path)
    106 }
    107 
    108 fn resolve_path_tool_from_path(name: &str, path: &std::ffi::OsStr) -> Result<PathBuf, String> {
    109     let matches = executable_matches(name, path);
    110     match matches.as_slice() {
    111         [] => Err(format!(
    112             "missing {name}: install {name} and rerun `cargo xtask generate wasm`"
    113         )),
    114         [tool] => Ok(tool.clone()),
    115         _ => Err(format!(
    116             "ambiguous {name}: found {}; remove duplicate {name} entries from PATH before running `cargo xtask generate wasm`",
    117             matches
    118                 .iter()
    119                 .map(|path| path.display().to_string())
    120                 .collect::<Vec<_>>()
    121                 .join(", ")
    122         )),
    123     }
    124 }
    125 
    126 fn executable_matches(name: &str, path: &std::ffi::OsStr) -> Vec<PathBuf> {
    127     let mut seen = BTreeSet::new();
    128     let mut matches = Vec::new();
    129     for dir in env::split_paths(path) {
    130         let candidate = dir.join(name);
    131         if !is_executable_file(&candidate) {
    132             continue;
    133         }
    134         let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone());
    135         if seen.insert(key) {
    136             matches.push(candidate);
    137         }
    138     }
    139     matches
    140 }
    141 
    142 fn is_executable_file(path: &Path) -> bool {
    143     if !path.is_file() {
    144         return false;
    145     }
    146     #[cfg(unix)]
    147     {
    148         use std::os::unix::fs::PermissionsExt;
    149         path.metadata()
    150             .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
    151             .unwrap_or(false)
    152     }
    153     #[cfg(not(unix))]
    154     {
    155         true
    156     }
    157 }
    158 
    159 fn resolve_required_rust_tool(name: &str, env_var: &str) -> Result<PathBuf, String> {
    160     if let Some(path) = explicit_tool_path(env_var) {
    161         return Ok(PathBuf::from(path));
    162     }
    163     rustup_tool(name).ok_or_else(|| {
    164         format!(
    165             "missing rustup resolution for {name}: set {env_var} explicitly or install rustup with the {WASM_TARGET} target"
    166         )
    167     })
    168 }
    169 
    170 fn explicit_tool_path(env_var: &str) -> Option<String> {
    171     let value = env::var(env_var).ok()?;
    172     let trimmed = value.trim();
    173     (!trimmed.is_empty()).then(|| trimmed.to_owned())
    174 }
    175 
    176 fn rustup_tool(name: &str) -> Option<PathBuf> {
    177     let output = Command::new("rustup")
    178         .arg("which")
    179         .arg(name)
    180         .output()
    181         .ok()?;
    182     if !output.status.success() {
    183         return None;
    184     }
    185     let path = String::from_utf8(output.stdout).ok()?;
    186     let trimmed = path.trim();
    187     (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
    188 }
    189 
    190 fn ensure_wasm_target_installed() -> Result<(), String> {
    191     let output = Command::new("rustup")
    192         .arg("target")
    193         .arg("list")
    194         .arg("--installed")
    195         .output()
    196         .map_err(|error| {
    197             format!(
    198                 "failed to verify {WASM_TARGET} target with rustup: {error}; install rustup or set RUSTC/CARGO from a toolchain that supports {WASM_TARGET}"
    199             )
    200         })?;
    201     if !output.status.success() {
    202         let stderr = String::from_utf8_lossy(&output.stderr);
    203         return Err(format!(
    204             "failed to verify {WASM_TARGET} target with rustup: {}; run `rustup target add {WASM_TARGET}`",
    205             stderr.trim()
    206         ));
    207     }
    208     let stdout = String::from_utf8_lossy(&output.stdout);
    209     if !target_list_contains(&stdout, WASM_TARGET) {
    210         return Err(format!(
    211             "missing Rust target {WASM_TARGET}: run `rustup target add {WASM_TARGET}`"
    212         ));
    213     }
    214     Ok(())
    215 }
    216 
    217 fn target_list_contains(output: &str, target: &str) -> bool {
    218     output.lines().any(|line| line.trim() == target)
    219 }
    220 
    221 fn prepend_path(command: &mut Command, prefix: &Path) {
    222     let existing = env::var_os("PATH").unwrap_or_default();
    223     let mut paths = vec![prefix.to_path_buf()];
    224     paths.extend(env::split_paths(&existing));
    225     if let Ok(joined) = env::join_paths(paths) {
    226         command.env("PATH", joined);
    227     }
    228 }
    229 
    230 #[cfg(test)]
    231 mod tests {
    232     use std::{
    233         env, fs,
    234         path::PathBuf,
    235         time::{SystemTime, UNIX_EPOCH},
    236     };
    237 
    238     use crate::package_matrix::wasm_package_specs;
    239 
    240     use super::{
    241         resolve_path_tool_from_path, rustup_tool, selected_specs, target_list_contains,
    242         wasm_pack_args,
    243     };
    244 
    245     #[test]
    246     fn selects_all_specs_by_default() {
    247         assert_eq!(selected_specs(&[]).expect("all specs").len(), 3);
    248     }
    249 
    250     #[test]
    251     fn selects_one_spec_by_key() {
    252         let specs = selected_specs(&["--package".to_owned(), "replica_db".to_owned()])
    253             .expect("replica db spec");
    254         assert_eq!(specs[0].package_name, "@radroots/replica-db-wasm");
    255     }
    256 
    257     #[test]
    258     fn rejects_unknown_spec_key() {
    259         assert!(selected_specs(&["--package".to_owned(), "missing".to_owned()]).is_err());
    260     }
    261 
    262     #[test]
    263     fn wasm_pack_arguments_disable_package_manifest_generation() {
    264         let args = wasm_pack_args(wasm_package_specs()[0]);
    265         assert!(args.contains(&"--no-pack"));
    266     }
    267 
    268     #[test]
    269     fn path_tool_resolution_reports_missing_tools() {
    270         let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new(""))
    271             .expect_err("missing");
    272         assert!(error.contains("missing wasm-pack"));
    273     }
    274 
    275     #[test]
    276     fn path_tool_resolution_reports_ambiguous_tools() {
    277         let root = test_root("ambiguous_wasm_pack");
    278         let first = root.join("first");
    279         let second = root.join("second");
    280         fs::create_dir_all(&first).expect("create first dir");
    281         fs::create_dir_all(&second).expect("create second dir");
    282         write_executable(first.join("wasm-pack"));
    283         write_executable(second.join("wasm-pack"));
    284         let path = env::join_paths([first, second]).expect("join path");
    285 
    286         let error =
    287             resolve_path_tool_from_path("wasm-pack", &path).expect_err("ambiguous wasm-pack");
    288 
    289         assert!(error.contains("ambiguous wasm-pack"));
    290         let _ = fs::remove_dir_all(root);
    291     }
    292 
    293     #[test]
    294     fn target_list_parser_requires_exact_target() {
    295         assert!(target_list_contains(
    296             "aarch64-apple-darwin\nwasm32-unknown-unknown\n",
    297             "wasm32-unknown-unknown"
    298         ));
    299         assert!(!target_list_contains(
    300             "wasm32-unknown-emscripten\n",
    301             "wasm32-unknown-unknown"
    302         ));
    303     }
    304 
    305     #[test]
    306     fn rustup_tool_resolution_is_non_panicking() {
    307         let _ = rustup_tool("rustc");
    308     }
    309 
    310     fn write_executable(path: PathBuf) {
    311         fs::write(&path, "#!/bin/sh\n").expect("write executable");
    312         #[cfg(unix)]
    313         {
    314             use std::os::unix::fs::PermissionsExt;
    315             let mut permissions = fs::metadata(&path).expect("metadata").permissions();
    316             permissions.set_mode(0o755);
    317             fs::set_permissions(&path, permissions).expect("set executable permissions");
    318         }
    319     }
    320 
    321     fn test_root(name: &str) -> PathBuf {
    322         let stamp = SystemTime::now()
    323             .duration_since(UNIX_EPOCH)
    324             .expect("system time")
    325             .as_nanos();
    326         env::temp_dir().join(format!("radroots_sdk_xtask_{name}_{stamp}"))
    327     }
    328 }