use std::{
collections::BTreeMap,
ffi::OsString,
fs,
path::{Path, PathBuf},
process::Command,
};
use anyhow::anyhow;
use tempfile::TempDir;
use tinyjson::JsonValue;
use toml_edit::Table;
macro_rules! warn {
($($tokens: tt)*) => {
println!("cargo:warning={}", format!($($tokens)*).replace('\n', "\\n"))
}
}
macro_rules! debug {
($($tokens: tt)*) => {
#[cfg(feature = "build-log-debug")]
{ println!("cargo:warning=debug: {}", format!($($tokens)*).replace('\n', "\\n")); }
#[cfg(not(feature = "build-log-debug"))]
{ let _ = format!($($tokens)*);}
}
}
const ENV_BUILD_FRONTEND: &str = "MACHINE_CHECK_GUI_BUILD_FRONTEND";
const ENV_FORCE_NO_BUILD: &str = "MACHINE_CHECK_GUI_FORCE_NO_BUILD";
const ENV_PREPARE_FRONTEND: &str = "MACHINE_CHECK_GUI_PREPARE_FRONTEND";
const ENV_WASM_DIR: &str = "MACHINE_CHECK_GUI_WASM_DIR";
const ENV_POSTPONE_ERRORS: &str = "MACHINE_CHECK_GUI_POSTPONE_ERRORS";
const ENV_VARIABLES: [&str; 2] = [ENV_WASM_DIR, ENV_POSTPONE_ERRORS];
fn main() {
if let Err(err) = run() {
panic!("Error building WASM frontend: {}", err);
}
}
fn run() -> anyhow::Result<()> {
let prepare = bool_env(ENV_PREPARE_FRONTEND, false)?;
let mut should_build = bool_env(ENV_BUILD_FRONTEND, false)?;
if bool_env(ENV_FORCE_NO_BUILD, false)? {
warn!("Forcing no frontend WASM build.");
should_build = false;
}
let arrangement = arrange(should_build, prepare)?;
if should_build {
build(&arrangement)?;
} else {
ensure_wasm_hash(&arrangement.artifact_dir, arrangement.hex_hash.clone())?;
}
if let Some(frontend_package_tempdir) = arrangement.frontend_package_tempdir {
if let Err(err) = frontend_package_tempdir.close() {
warn!("Could not close WASM temporary directory: {}", err)
}
};
Ok(())
}
fn ensure_wasm_hash(artifact_dir: &Path, hex_hash: String) -> anyhow::Result<()> {
let prebuilt_hex_hash =
fs::read_to_string(artifact_dir.join(HASH_FILE_NAME)).map_err(|err| {
anyhow!(
"Could not read the hash file of prebuilt WebAssembly frontend: {}",
err
)
})?;
if hex_hash != prebuilt_hex_hash {
return Err(anyhow!(
"The prebuilt WebAssembly frontend does not match the source code (our hash {}, prebuilt hash {})",hex_hash, prebuilt_hex_hash
));
}
Ok(())
}
fn build(arrangement: &Arrangement) -> anyhow::Result<()> {
let _ = fs::remove_dir_all(&arrangement.artifact_dir);
match compile_frontend_package(arrangement) {
Ok(None) => {}
Ok(Some(postponed)) => {
warn!(
"WASM frontend build failed (postponing error): {}",
postponed
)
}
Err(err) => return Err(anyhow!("Build failed: {}", err)),
}
Ok(())
}
struct Arrangement {
prepare: bool,
this_package_dir: PathBuf,
frontend_package_tempdir: Option<TempDir>,
frontend_package_dir: PathBuf,
artifact_dir: PathBuf,
postpone_build_errors: bool,
hex_hash: String,
}
fn arrange(build: bool, prepare: bool) -> anyhow::Result<Arrangement> {
let this_package_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
std::env::set_current_dir(this_package_dir)
.expect("Should be able to move to manifest directory");
COPY_DIRECTORIES
.into_iter()
.chain(COPY_FILES)
.chain(SPECIAL_FILES)
.for_each(|path| {
println!("cargo::rerun-if-changed={}", path);
});
for env_variable in ENV_VARIABLES {
println!("cargo::rerun-if-env-changed={}", env_variable);
}
let postpone_build_errors = bool_env(ENV_POSTPONE_ERRORS, false)?;
let mut wasm_dir = std::env::var_os(ENV_WASM_DIR);
let (frontend_package_dir, frontend_package_tempdir) = if build {
if prepare {
wasm_dir = None;
}
match wasm_dir {
Some(wasm_dir) => {
fs::create_dir_all(wasm_dir.clone())
.expect("Should be able to move to WASM directory");
let wasm_dir = fs::canonicalize(wasm_dir)
.expect("Should be able to canonicalize WASM directory");
(wasm_dir, None)
}
None => {
let tempdir = tempfile::TempDir::with_prefix("wasm_crate_")
.expect("Should be able to create a temporary directory");
(tempdir.path().to_path_buf(), Some(tempdir))
}
}
} else {
let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR should be set"));
(out_dir.join("machine-check-gui-wasm"), None)
};
debug!("Frontend package directory: {:?}", frontend_package_dir);
let package_toml_path = cargo_toml_path(false)?;
let workspace_toml_path = cargo_toml_path(true)?;
let mut workspace_toml_path = if package_toml_path != workspace_toml_path {
Some(workspace_toml_path)
} else {
None
};
if prepare {
warn!("Preparing the frontend WebAssembly for deployment. Make sure you know what you are doing.");
let _ = fs::remove_dir_all(&frontend_package_dir);
if workspace_toml_path.is_some() {
warn!("Overriding workspace build due to deployment preparation.");
workspace_toml_path = None;
}
}
let artifact_dir = this_package_dir.join(ARTIFACT_DIR);
let hex_hash = arrange_frontend_package(
this_package_dir,
&frontend_package_dir,
package_toml_path,
workspace_toml_path,
)
.map_err(|err| anyhow!("Package preparation failed: {}", err))?;
Ok(Arrangement {
prepare,
this_package_dir: this_package_dir.to_path_buf(),
frontend_package_tempdir,
frontend_package_dir: frontend_package_dir.to_path_buf(),
artifact_dir: artifact_dir.to_path_buf(),
postpone_build_errors,
hex_hash,
})
}
const COPY_DIRECTORIES: [&str; 2] = ["src/shared", "src/frontend"];
const COPY_FILES: [&str; 2] = ["src/frontend.rs", "src/shared.rs"];
const LIB_RS: &str = "src/lib.rs";
const CARGO_TOML: &str = "Cargo.toml";
const RUST_TOOLCHAIN_TOML: &str = "rust-toolchain.toml";
const SPECIAL_FILES: [&str; 2] = [LIB_RS, CARGO_TOML];
const ARTIFACT_DIR: &str = "content/wasm";
const HASH_FILE_NAME: &str = "hash.hex";
fn arrange_frontend_package(
this_package_dir: &Path,
frontend_package_dir: &Path,
package_toml_path: PathBuf,
workspace_toml_path: Option<PathBuf>,
) -> anyhow::Result<String> {
if this_package_dir.join(RUST_TOOLCHAIN_TOML).exists() {
return Err(anyhow!(
"A rust-toolchain.toml in the package directory is not supported"
));
}
for copy_directory in COPY_DIRECTORIES {
copy_dir_all(
this_package_dir.join(copy_directory),
frontend_package_dir.join(copy_directory),
)?;
}
for copy_file in COPY_FILES {
fs::copy(
this_package_dir.join(copy_file),
frontend_package_dir.join(copy_file),
)?;
}
fs::write(
frontend_package_dir.join(LIB_RS),
"#![allow(clippy::all)]\npub mod shared; mod frontend;\n",
)?;
let cargo_toml = fs::read_to_string(this_package_dir.join(CARGO_TOML))?;
let mut cargo_toml: toml_edit::DocumentMut = cargo_toml.parse()?;
if cargo_toml.contains_key("workspace") {
return Err(anyhow!(
"Workspace entry in package Cargo.toml not supported"
));
}
if cargo_toml.contains_key("patch") {
return Err(anyhow!("Patch entry in package Cargo.toml not supported"));
}
if cargo_toml.contains_key("replace") {
return Err(anyhow!("Replace entry in package Cargo.toml not supported"));
}
cargo_toml["package"]["name"] = "machine-check-gui-wasm".into();
cargo_toml.insert("workspace", toml_edit::Item::Table(Table::new()));
cargo_toml["package"]["publish"] = false.into();
canonicalize_paths(&package_toml_path, &mut cargo_toml["dependencies"])?;
let mut patched_repository_package_paths: BTreeMap<String, BTreeMap<String, String>> =
BTreeMap::new();
if let Some(workspace_toml_path) = workspace_toml_path {
let workspace_toml = fs::read_to_string(&workspace_toml_path)?;
let workspace_toml: toml_edit::DocumentMut = workspace_toml.parse()?;
let package_workspace = cargo_toml
.get_mut("workspace")
.unwrap()
.as_table_mut()
.unwrap();
if let Some(workspace_dependencies) = workspace_toml["workspace"].get("dependencies") {
let mut workspace_dependencies = workspace_dependencies.clone();
canonicalize_paths(&workspace_toml_path, &mut workspace_dependencies)?;
package_workspace.insert("dependencies", workspace_dependencies);
}
if let Some(workspace_lints) = workspace_toml["workspace"].get("lints") {
package_workspace.insert("lints", workspace_lints.clone());
}
if workspace_toml.contains_key("replace") {
return Err(anyhow!(
"Replace entry in workspace Cargo.toml not supported. Consider using patch."
));
}
if let Some(patch) = workspace_toml.get("patch") {
let mut patch = patch.clone();
let Some(patch_table) = patch.as_table_mut() else {
return Err(anyhow!(
"Unexpected non-table workspace patch in {:?}",
workspace_toml_path
));
};
for (repository_key, repository_value) in patch_table.iter_mut() {
let patched_package_paths =
canonicalize_paths(&workspace_toml_path, repository_value)?;
let entry = patched_repository_package_paths
.entry(repository_key.to_string())
.or_default();
entry.extend(patched_package_paths.into_iter());
}
debug!("Applying workspace patch to WASM package");
cargo_toml.insert("patch", patch);
}
let workspace_rust_toolchain_toml = workspace_toml_path
.parent()
.ok_or(anyhow!("Workspace Cargo.toml should have a parent"))?
.join(RUST_TOOLCHAIN_TOML);
if workspace_rust_toolchain_toml.exists() {
fs::copy(
workspace_rust_toolchain_toml.clone(),
frontend_package_dir.join(RUST_TOOLCHAIN_TOML),
)
.map_err(|err| anyhow!("Workspace rust-toolchain.toml could not be copied: {}", err))?;
}
cargo_rerun_if_path_changed(&workspace_toml_path)?;
cargo_rerun_if_path_changed(&workspace_rust_toolchain_toml)?;
}
let dependencies = cargo_toml
.get("dependencies")
.ok_or(anyhow!("Expected dependencies in Cargo.toml"))?;
let dependencies = dependencies
.as_table()
.ok_or(anyhow!("Unexpected non-table dependencies in Cargo.toml"))?;
for (dependency_name, dependency) in dependencies {
let registry = if let Some(dependency) = dependency.as_table_like() {
if let Some(registry) = dependency.get("registry") {
Some(
registry
.as_str()
.ok_or(anyhow!("Unexpected non-string registry in Cargo.toml"))?,
)
} else {
None
}
} else {
None
};
let registry = registry.unwrap_or("crates-io");
if let Some(patched_package_paths) = patched_repository_package_paths.get(registry) {
if let Some(patched_path) = patched_package_paths.get(dependency_name) {
debug!(
"Ensuring rebuild on repository {} dependency {} patched path {:?}",
dependency_name, registry, patched_path
);
println!("cargo::rerun-if-changed={}", patched_path);
}
}
}
let hex_hash = directory_hex_hash(String::from(
frontend_package_dir
.to_str()
.expect("Frontend package directory path should be UTF-8"),
))?;
fs::write(
frontend_package_dir.join(CARGO_TOML),
cargo_toml.to_string(),
)?;
fs::write(frontend_package_dir.join(".gitignore"), "*\n")?;
Ok(hex_hash)
}
fn compile_frontend_package(arrangement: &Arrangement) -> anyhow::Result<Option<anyhow::Error>> {
let cargo_target_dir: PathBuf = arrangement.frontend_package_dir.join("target");
let cargo_target_dir_arg = create_equals_arg("target-dir", &cargo_target_dir);
std::env::set_current_dir(&arrangement.frontend_package_dir)
.map_err(|err| anyhow!("Cannot set current frontend package dir: {}", err))?;
let mut cargo_build = Command::new("cargo");
cargo_build
.current_dir(&arrangement.frontend_package_dir)
.args([
"build",
"--package",
"machine-check-gui-wasm",
"--target=wasm32-unknown-unknown",
]);
let profile = std::env::var("PROFILE")
.map_err(|err| anyhow!("Cannot retrieve build profile: {}", err))?;
if profile == "debug" {
} else if profile == "release" {
cargo_build.arg("--release");
} else {
return Err(anyhow!("Unknown build profile {}", profile));
};
cargo_build.arg(cargo_target_dir_arg);
if let Err(err) = execute_command("cargo build", cargo_build) {
let err = anyhow!("Cannot build using cargo: {}", err);
if arrangement.postpone_build_errors {
return Ok(Some(err));
} else {
return Err(err);
}
}
let bindgen_out_dir_arg = create_equals_arg("out-dir", &arrangement.artifact_dir);
std::env::set_current_dir(&arrangement.this_package_dir)?;
let mut wasm_bindgen = Command::new("wasm-bindgen");
let target_path = format!(
"wasm32-unknown-unknown/{}/machine_check_gui_wasm.wasm",
profile
);
wasm_bindgen
.current_dir(&arrangement.this_package_dir)
.arg("--target=web")
.arg(bindgen_out_dir_arg)
.arg(cargo_target_dir.join(target_path));
execute_command("wasm-bindgen", wasm_bindgen).map_err(|err| anyhow!("Cannot generate bindings using wasm-bindgen: {}", err))?;
if arrangement.prepare {
fs::write(
arrangement.artifact_dir.join(HASH_FILE_NAME),
arrangement.hex_hash.clone(),
)
.map_err(|err| anyhow!("Cannot write frontend directory hash value: {err}"))?;
warn!("Frontend WebAssembly prepared for deployment.")
}
Ok(None)
}
fn canonicalize_paths(
orig_toml_path: &Path,
package_list: &mut toml_edit::Item,
) -> anyhow::Result<BTreeMap<String, String>> {
let Some(orig_directory) = orig_toml_path.parent() else {
return Err(anyhow!("No parent of TOML file path {:?}", orig_toml_path));
};
std::env::set_current_dir(orig_directory).map_err(|err| {
anyhow!(
"Cannot change the current working directory to {:?}: {}",
orig_directory,
err
)
})?;
let Some(package_list) = package_list.as_table_mut() else {
return Err(anyhow!(
"Unexpected non-table package list {} (in {:?})",
package_list,
orig_toml_path
));
};
let mut package_name_paths = BTreeMap::new();
for (package_key, package_value) in package_list.iter_mut() {
let Some(package_value) = package_value.as_table_like_mut() else {
if package_value.is_str() {
continue;
}
return Err(anyhow!(
"Unexpected non-inline-table package value for {}: '{}' (in {:?})",
package_key,
package_value,
orig_toml_path
));
};
for (key, value) in package_value.iter_mut() {
if key != "path" {
continue;
}
let package_path = value.as_str().unwrap();
let canonical_path = fs::canonicalize(package_path)?;
debug!(
"Adjusting {} path to canonical {:?}",
package_key, canonical_path
);
let canonical_path_string = canonical_path.to_str().ok_or_else(|| {
anyhow!("Unexpected non-UTF-8 dependency path: {:?}", canonical_path)
})?;
package_name_paths.insert(package_key.to_string(), canonical_path_string.to_string());
*value = toml_edit::Item::Value(toml_edit::Value::String(toml_edit::Formatted::new(
canonical_path.into_os_string().into_string().unwrap(),
)));
}
}
Ok(package_name_paths)
}
fn cargo_toml_path(workspace: bool) -> anyhow::Result<PathBuf> {
let mut command = std::process::Command::new(env!("CARGO"));
command.arg("locate-project");
if workspace {
command.arg("--workspace");
}
command.arg("--message-format=json");
let output = command
.output()
.map_err(|err| anyhow!("Should be executable: {}", err))?;
if !output.status.success() {
return Err(anyhow!("Non-success status code {}", output.status));
}
let json_value: JsonValue = std::str::from_utf8(&output.stdout)
.map_err(|err| anyhow!("Output should be UTF-8: {}", err))?
.parse()
.map_err(|err| anyhow!("Output should be JSON: {}", err))?;
let json_object: &std::collections::HashMap<_, _> = json_value
.get()
.ok_or(anyhow!("Output should be an object"))?;
let json_root = json_object
.get("root")
.ok_or(anyhow!("Output should have a 'root' element"))?;
let json_root: &String = json_root
.get()
.ok_or(anyhow!("The 'root' element should be a string"))?;
debug!(
"Cargo.toml path ({}): {}",
if workspace { "workspace" } else { "package" },
json_root
);
Ok(PathBuf::from(json_root))
}
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
fs::create_dir_all(&dst)?;
for dir_entry in fs::read_dir(src)? {
let dir_entry = dir_entry?;
let file_type = dir_entry.file_type()?;
if file_type.is_dir() {
copy_dir_all(dir_entry.path(), dst.as_ref().join(dir_entry.file_name()))?;
} else {
fs::copy(dir_entry.path(), dst.as_ref().join(dir_entry.file_name()))?;
}
}
Ok(())
}
fn execute_command(name: &str, mut command: Command) -> anyhow::Result<()> {
let output = command.output()?;
if !output.status.success() {
Err(anyhow!(
"{} failed, status code: {}\n --- Output:\n{}",
name,
output.status,
String::from_utf8(output.stderr)?
))
} else {
debug!(
"{} succeeded, status code: {}\n --- Output:\n{}",
name,
output.status,
String::from_utf8(output.stderr)?
);
Ok(())
}
}
fn create_equals_arg(arg_name: &str, path: &Path) -> OsString {
let mut result = OsString::from("--");
result.push(arg_name);
result.push("=");
result.push(path.as_os_str());
result
}
fn bool_env(name: &str, default: bool) -> anyhow::Result<bool> {
println!("cargo::rerun-if-env-changed={}", name);
let val = std::env::var_os(name);
debug!(
"Bool env var {} value: {:?} (default {})",
name, val, default
);
let Some(val) = val else { return Ok(default) };
if val.eq_ignore_ascii_case("true") {
Ok(true)
} else if val.eq_ignore_ascii_case("false") {
Ok(false)
} else {
Err(anyhow!("The environment variable '{}' should have a Boolean value (true or false ignoring case).", name))
}
}
fn directory_hex_hash(absolute_path: String) -> anyhow::Result<String> {
let hash_tree = merkle_hash::MerkleTree::builder(&absolute_path)
.hash_names(false)
.build()
.map_err(|err| anyhow!("Cannot hash frontend package files: {}", err))?;
let path_hash = hash_tree.root.item.hash;
Ok(hex::encode(&path_hash))
}
fn cargo_rerun_if_path_changed(path: &Path) -> anyhow::Result<()> {
let path_str = path
.to_str()
.ok_or(anyhow!("Workspace Cargo.toml should have Unicode path"))?;
println!("cargo::rerun-if-changed={}", path_str);
Ok(())
}