1use std::io::BufRead;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5pub struct Options {
6 pub bin: String,
7 pub package: Option<String>,
8 pub release: bool,
9 pub public_url: String,
10 pub features: Vec<String>,
11 pub manifest_path: Option<PathBuf>,
12 pub out_dir: Option<PathBuf>,
13}
14
15impl Options {
16 fn package(&self) -> &str {
17 self.package.as_deref().unwrap_or(&self.bin)
18 }
19}
20
21pub fn parse(args: &[String]) -> Result<Options, String> {
22 let mut opts = Options {
23 bin: "polytope_playground".to_string(),
24 package: None,
25 release: false,
26 public_url: "/".to_string(),
27 features: Vec::new(),
28 manifest_path: None,
29 out_dir: None,
30 };
31 let mut args = args.iter();
32 while let Some(arg) = args.next() {
33 match arg.as_str() {
34 "--release" => opts.release = true,
35 "--bin" => opts.bin = args.next().cloned().ok_or("--bin needs a name")?,
36 "--package" => {
37 opts.package = Some(args.next().cloned().ok_or("--package needs a name")?);
38 }
39 "--public-url" => {
40 opts.public_url = args.next().cloned().ok_or("--public-url needs a path")?;
41 }
42 "--features" => {
43 let list = args.next().cloned().ok_or("--features needs a list")?;
44 opts.features.extend(
45 list.split(',')
46 .filter(|name| !name.is_empty())
47 .map(String::from),
48 );
49 }
50 "--manifest-path" => {
51 let path = args.next().ok_or("--manifest-path needs a Cargo.toml")?;
52 opts.manifest_path = Some(PathBuf::from(path));
53 }
54 "--out-dir" => {
55 let dir = args.next().ok_or("--out-dir needs a directory")?;
56 opts.out_dir = Some(PathBuf::from(dir));
57 }
58 other => return Err(format!("unknown flag: {other}")),
59 }
60 }
61 if !opts.public_url.ends_with('/') {
62 opts.public_url.push('/');
63 }
64 Ok(opts)
65}
66
67pub fn run(opts: &Options) -> Result<(), String> {
68 let root = crate::workspace_root()?;
69 let manifest = opts
70 .manifest_path
71 .as_deref()
72 .map(std::path::absolute)
73 .transpose()
74 .map_err(|e| format!("--manifest-path: {e}"))?;
75 let manifest_dir = match &manifest {
76 Some(path) => path
77 .parent()
78 .ok_or_else(|| format!("{} has no directory", path.display()))?
79 .to_path_buf(),
80 None => root.clone(),
81 };
82
83 let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
84 let mut command = Command::new(cargo);
85 command
86 .current_dir(&manifest_dir)
87 .args(["build", "--target", "wasm32-unknown-unknown"])
88 .args(["-p", opts.package(), "--bin", &opts.bin])
89 .arg("--no-default-features")
90 .arg("--message-format=json-render-diagnostics")
91 .stdout(Stdio::piped());
92 if let Some(manifest) = &manifest {
93 command.arg("--manifest-path").arg(manifest);
94 }
95 if opts.release {
96 command.args(["--profile", "wasm-release"]);
97 remap_paths(&mut command, &root)?;
98 }
99 for feature in &opts.features {
100 if let Some((package, _)) = feature.split_once('/') {
101 command.args(["-p", package]);
102 }
103 command.args(["--features", feature]);
104 }
105 let mut child = command.spawn().map_err(|e| format!("run cargo: {e}"))?;
106 let stdout = child.stdout.take().ok_or("cargo stdout is not piped")?;
107 let wasm = wasm_artifact(std::io::BufReader::new(stdout), &opts.bin);
108 let status = child.wait().map_err(|e| format!("wait for cargo: {e}"))?;
109 if !status.success() {
110 return Err(format!("cargo build failed: {status}"));
111 }
112 let wasm = wasm?;
113
114 let out_dir = opts
115 .out_dir
116 .clone()
117 .unwrap_or_else(|| root.join("dist").join(&opts.bin));
118 std::fs::create_dir_all(&out_dir).map_err(|e| format!("create {}: {e}", out_dir.display()))?;
119
120 wasm_bindgen_cli_support::Bindgen::new()
121 .input_path(&wasm)
122 .out_name(&opts.bin)
123 .web(true)
124 .map_err(|e| format!("wasm-bindgen: {e:#}"))?
125 .typescript(false)
126 .omit_default_module_path(false)
127 .remove_name_section(opts.release)
128 .generate(&out_dir)
129 .map_err(|e| format!("wasm-bindgen: {e:#}"))?;
130
131 optimize(&out_dir.join(format!("{}_bg.wasm", opts.bin)), opts.release)?;
132
133 let template = if manifest.is_some() {
134 let own = manifest_dir.join("index.html");
135 own.exists().then_some(own)
136 } else {
137 let package = root.join("crates").join(opts.package());
138 let session = root.join("crates/loam-app/static/session_page.html");
139 let own = [
140 package.join(format!("{}.html", opts.bin)),
141 package.join("index.html"),
142 ];
143 Some(
144 own.into_iter()
145 .find(|path| path.exists())
146 .unwrap_or(session),
147 )
148 };
149 if let Some(template) = template {
150 let dest = out_dir.join("index.html");
151 let same = std::fs::canonicalize(&dest)
152 .is_ok_and(|d| std::fs::canonicalize(&template).is_ok_and(|t| t == d));
153 if same {
154 return Err(format!(
155 "--out-dir would overwrite the template {}",
156 template.display()
157 ));
158 }
159 let page = render_page(&root, &manifest_dir, &template, opts)?;
160 std::fs::write(dest, page).map_err(|e| format!("write index.html: {e}"))?;
161 } else {
162 println!(
163 "index.html skipped: no {}",
164 manifest_dir.join("index.html").display()
165 );
166 }
167
168 let entries =
169 std::fs::read_dir(&out_dir).map_err(|e| format!("read {}: {e}", out_dir.display()))?;
170 for entry in entries.flatten() {
171 let size = entry.metadata().map_or(0, |meta| meta.len());
172 println!("{} ({size} bytes)", entry.path().display());
173 }
174 Ok(())
175}
176
177fn remap_paths(command: &mut Command, root: &Path) -> Result<(), String> {
179 let home = std::env::home_dir();
180 let cargo_home = std::env::var_os("CARGO_HOME")
181 .map(PathBuf::from)
182 .or_else(|| home.as_ref().map(|home| home.join(".cargo")));
183 let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
184 let sysroot = Command::new(rustc)
185 .args(["--print", "sysroot"])
186 .output()
187 .map_err(|e| format!("rustc --print sysroot: {e}"))?;
188 let sysroot = PathBuf::from(String::from_utf8_lossy(&sysroot.stdout).trim());
189 let flags: Vec<String> = [
190 (home, "~"),
191 (cargo_home, "cargo"),
192 (Some(sysroot), "rust"),
193 (Some(root.to_path_buf()), "loam"),
194 ]
195 .into_iter()
196 .filter_map(|(from, to)| {
197 from.map(|from| format!("--remap-path-prefix={}={to}", from.display()))
198 })
199 .collect();
200 let set = std::env::var("CARGO_ENCODED_RUSTFLAGS").ok().or_else(|| {
202 std::env::var("RUSTFLAGS")
203 .ok()
204 .map(|flags| flags.split_whitespace().collect::<Vec<_>>().join("\u{1f}"))
205 });
206 match set {
207 Some(set) => {
208 let all: Vec<String> = std::iter::once(set)
209 .filter(|set| !set.is_empty())
210 .chain(flags)
211 .collect();
212 command.env("CARGO_ENCODED_RUSTFLAGS", all.join("\u{1f}"));
213 }
214 None => {
215 let array = serde_json::to_string(&flags).map_err(|e| format!("remap flags: {e}"))?;
216 command
217 .arg("--config")
218 .arg(format!("target.wasm32-unknown-unknown.rustflags={array}"));
219 }
220 }
221 Ok(())
222}
223
224fn wasm_artifact(messages: impl BufRead, bin: &str) -> Result<PathBuf, String> {
225 let mut found = None;
226 for line in messages.lines() {
227 let line = line.map_err(|e| format!("read cargo output: {e}"))?;
228 let Ok(message) = serde_json::from_str::<serde_json::Value>(&line) else {
229 continue;
230 };
231 if message["reason"] != "compiler-artifact" {
232 continue;
233 }
234 let target = &message["target"];
235 let is_bin = target["kind"]
236 .as_array()
237 .is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin"));
238 if !is_bin || target["name"] != bin {
239 continue;
240 }
241 if let Some(path) = message["executable"].as_str() {
242 found = Some(PathBuf::from(path));
243 }
244 }
245 found.ok_or_else(|| format!("cargo built no bin named {bin}"))
246}
247
248fn optimize(wasm: &Path, release: bool) -> Result<(), String> {
249 if !release {
250 println!("wasm-opt skipped: not a --release build");
251 return Ok(());
252 }
253 let optimized = wasm.with_extension("opt.wasm");
254 let result = Command::new("wasm-opt")
255 .args(["-Oz", "-o"])
256 .arg(&optimized)
257 .arg(wasm)
258 .status();
259 match result {
260 Ok(status) if status.success() => std::fs::rename(&optimized, wasm)
261 .map_err(|e| format!("replace {}: {e}", wasm.display())),
262 Ok(status) => Err(format!("wasm-opt failed: {status}")),
263 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
264 println!("wasm-opt skipped: not on PATH");
265 Ok(())
266 }
267 Err(e) => Err(format!("run wasm-opt: {e}")),
268 }
269}
270
271fn render_page(
272 root: &Path,
273 manifest_dir: &Path,
274 template: &Path,
275 opts: &Options,
276) -> Result<String, String> {
277 let read = |path: &Path| {
278 std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))
279 };
280 let template = read(template)?;
281 let loader = read(&root.join("crates/loam-app/static/page_loader.html"))?;
282 let hash = git_hash(manifest_dir);
283 Ok(template
284 .replace("{{LOAM_PAGE_LOADER}}", &loader)
285 .replace("{{LOAM_PUBLIC_URL}}", &opts.public_url)
286 .replace("{{LOAM_BIN}}", &opts.bin)
287 .replace("{{LOAM_HASH}}", &hash))
288}
289
290fn git_hash(dir: &Path) -> String {
291 let git = |args: &[&str]| -> Option<String> {
292 let out = Command::new("git")
293 .current_dir(dir)
294 .args(args)
295 .output()
296 .ok()
297 .filter(|out| out.status.success())?;
298 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
299 };
300 let Some(hash) = git(&["rev-parse", "--short", "HEAD"]) else {
301 return "nogit".to_string();
302 };
303 let dirty = git(&["status", "--porcelain"]).is_some_and(|status| !status.is_empty());
304 if dirty {
305 format!("{hash}-dirty")
306 } else {
307 hash
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::wasm_artifact;
314
315 #[test]
316 fn wasm_artifact_ignores_other_units() {
317 let unit = |name: &str, kind: &str, executable: serde_json::Value| {
318 serde_json::json!({
319 "reason": "compiler-artifact",
320 "target": { "name": name, "kind": [kind] },
321 "executable": executable,
322 })
323 .to_string()
324 };
325 let lines = [
326 r#"{"reason":"build-script-executed","package_id":"examples"}"#.to_string(),
327 unit(
328 "build-script-build",
329 "custom-build",
330 "t/build/examples/build-script".into(),
331 ),
332 unit("loam_app", "lib", serde_json::Value::Null),
333 unit("hero", "bin", "t/wasm-release/hero.wasm".into()),
334 unit("backdrop", "bin", "t/wasm-release/backdrop.wasm".into()),
335 r#"{"reason":"build-finished","success":true}"#.to_string(),
336 ];
337 let wasm = wasm_artifact(lines.join("\n").as_bytes(), "backdrop").unwrap();
338 assert_eq!(wasm, std::path::Path::new("t/wasm-release/backdrop.wasm"));
339 }
340}