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 }