lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 4232c5e9c6dca190027ed09be88d56f9d3d34468
parent b3094a4922f880ddc75d50e558ccf1f15632549b
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 01:36:40 +0000

xtask: move automation to tools

- move the SDK xtask workspace member from crates/xtask to tools/xtask
- keep the cargo xtask alias and radroots_sdk_xtask package identity stable
- update local path dependencies for the new tool location
- preserve existing generation, check, and package validation commands

Diffstat:
Atools/sdk_xtask_import/Cargo.toml | 27+++++++++++++++++++++++++++
Atools/sdk_xtask_import/src/check.rs | 453+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/sdk_xtask_import/src/contracts.rs | 411+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/sdk_xtask_import/src/fs.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Atools/sdk_xtask_import/src/generate.rs | 16++++++++++++++++
Atools/sdk_xtask_import/src/main.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/sdk_xtask_import/src/manifest.rs | 36++++++++++++++++++++++++++++++++++++
Atools/sdk_xtask_import/src/output.rs | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/sdk_xtask_import/src/package_matrix.rs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/sdk_xtask_import/src/ts.rs | 37+++++++++++++++++++++++++++++++++++++
Atools/sdk_xtask_import/src/wasm.rs | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 1924 insertions(+), 0 deletions(-)

diff --git a/tools/sdk_xtask_import/Cargo.toml b/tools/sdk_xtask_import/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "radroots_sdk_xtask" +description = "Workspace automation for Radroots SDK crates" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[[bin]] +name = "radroots_sdk_xtask" +path = "src/main.rs" + +[dependencies] +radroots_sdk_binding_model = { path = "../../crates/binding_model" } +radroots_core_bindings = { path = "../../crates/core_bindings" } +radroots_events_bindings = { path = "../../crates/events_bindings" } +radroots_events_indexed_bindings = { path = "../../crates/events_indexed_bindings" } +radroots_identity_bindings = { path = "../../crates/identity_bindings" } +radroots_replica_db_schema_bindings = { path = "../../crates/replica_db_schema_bindings" } +radroots_types_bindings = { path = "../../crates/types_bindings" } +radroots_trade_bindings = { path = "../../crates/trade_bindings" } +serde_json = "1" +serde = { workspace = true, features = ["derive"] } +toml = "0.8" diff --git a/tools/sdk_xtask_import/src/check.rs b/tools/sdk_xtask_import/src/check.rs @@ -0,0 +1,453 @@ +use std::{collections::BTreeSet, fs, path::Path}; + +use crate::{ + contracts::validate_sdk_contracts, + fs::workspace_root, + output::package_outputs, + package_matrix::{ + FORBIDDEN_PACKAGE_NAMES, WasmPackageSpec, package_specs, validate_package_matrix, + wasm_package_specs, + }, +}; + +pub fn check() -> Result<(), String> { + validate_package_matrix()?; + let root = workspace_root()?; + validate_sdk_contracts(&root)?; + check_forbidden_packages(&root)?; + check_binding_crate_sources(&root)?; + for spec in package_specs() { + let package_dir = root.join(spec.package_dir); + let package_json_path = package_dir.join("package.json"); + let index_path = package_dir.join("src/index.ts"); + check_package_json(&package_json_path, spec.package_name)?; + if !index_path.is_file() { + return Err(format!("missing package index: {}", index_path.display())); + } + } + for spec in wasm_package_specs() { + check_wasm_package_surface(&root, *spec)?; + } + for output in package_outputs() { + for expected in output.files() { + let path = root + .join(output.spec.package_dir) + .join(expected.relative_path); + let actual = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + if actual != expected.contents { + return Err(format!("stale generated output: {}", path.display())); + } + } + } + Ok(()) +} + +fn check_binding_crate_sources(root: &Path) -> Result<(), String> { + for spec in package_specs() { + let crate_src_dir = root.join(spec.crate_dir).join("src"); + let typescript_dir = crate_src_dir.join("typescript"); + if typescript_dir.exists() { + return Err(format!( + "forbidden crate TypeScript source directory exists: {}", + typescript_dir.display() + )); + } + check_no_typescript_files(&crate_src_dir)?; + } + for spec in wasm_package_specs() { + check_no_typescript_files(&root.join(spec.crate_dir).join("src"))?; + } + Ok(()) +} + +fn check_no_typescript_files(dir: &Path) -> Result<(), String> { + for entry in + fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))? + { + let entry = + entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; + if file_type.is_dir() { + check_no_typescript_files(&path)?; + } else if file_type.is_file() + && path.extension().and_then(|extension| extension.to_str()) == Some("ts") + { + return Err(format!( + "forbidden crate TypeScript source file exists: {}", + path.display() + )); + } + } + Ok(()) +} + +fn check_forbidden_packages(root: &Path) -> Result<(), String> { + for forbidden in FORBIDDEN_PACKAGE_NAMES { + let package_leaf = forbidden.trim_start_matches("@radroots/").to_owned(); + let forbidden_dir = root.join("packages").join(package_leaf); + if forbidden_dir.exists() { + return Err(format!( + "forbidden package directory exists: {}", + forbidden_dir.display() + )); + } + } + Ok(()) +} + +fn check_package_json(path: &Path, expected_name: &str) -> Result<(), String> { + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let json = serde_json::from_str::<serde_json::Value>(&raw) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + let actual_name = json + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("package.json missing name: {}", path.display()))?; + if actual_name != expected_name { + return Err(format!( + "package name mismatch in {}: expected {expected_name}, found {actual_name}", + path.display() + )); + } + let private = json + .get("private") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if !private { + return Err(format!("package must be private: {}", path.display())); + } + Ok(()) +} + +pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { + let package_dir = root.join(spec.package_dir); + let package_json_path = package_dir.join("package.json"); + check_package_json(&package_json_path, spec.package_name)?; + let raw = fs::read_to_string(&package_json_path) + .map_err(|error| format!("failed to read {}: {error}", package_json_path.display()))?; + let json = serde_json::from_str::<serde_json::Value>(&raw) + .map_err(|error| format!("failed to parse {}: {error}", package_json_path.display()))?; + let dist_manifest = package_dir.join("dist").join("package.json"); + if dist_manifest.exists() { + return Err(format!( + "generated package manifest is forbidden: {}", + dist_manifest.display() + )); + } + for relative in package_surface_paths(&json, &package_json_path)? { + let normalized = relative.trim_start_matches("./"); + let path = package_dir.join(normalized); + if !path.is_file() { + return Err(format!( + "missing package export artifact for {}: {}", + spec.package_name, + path.display() + )); + } + } + Ok(()) +} + +fn package_surface_paths( + json: &serde_json::Value, + package_json_path: &Path, +) -> Result<BTreeSet<String>, String> { + let mut paths = BTreeSet::new(); + collect_required_package_path(json, package_json_path, "main", &mut paths)?; + collect_required_package_path(json, package_json_path, "types", &mut paths)?; + let exports = json.get("exports").ok_or_else(|| { + format!( + "package.json missing exports: {}", + package_json_path.display() + ) + })?; + match exports { + serde_json::Value::String(path) => { + validate_package_surface_path(path, package_json_path, "exports")?; + paths.insert(path.clone()); + } + serde_json::Value::Object(map) => { + if map.keys().any(|key| key != ".") { + return Err(format!( + "package.json only supports root exports: {}", + package_json_path.display() + )); + } + let root_export = map.get(".").ok_or_else(|| { + format!( + "package.json missing root export: {}", + package_json_path.display() + ) + })?; + collect_export_paths(root_export, package_json_path, "exports[\".\"]", &mut paths)?; + } + _ => { + return Err(format!( + "package.json exports must be a string or object: {}", + package_json_path.display() + )); + } + } + Ok(paths) +} + +fn collect_required_package_path( + json: &serde_json::Value, + package_json_path: &Path, + field: &'static str, + paths: &mut BTreeSet<String>, +) -> Result<(), String> { + let value = json + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "package.json missing {field}: {}", + package_json_path.display() + ) + })?; + validate_package_surface_path(value, package_json_path, field)?; + paths.insert(value.to_owned()); + Ok(()) +} + +fn collect_export_paths( + value: &serde_json::Value, + package_json_path: &Path, + field: &str, + paths: &mut BTreeSet<String>, +) -> Result<(), String> { + match value { + serde_json::Value::String(path) => { + validate_package_surface_path(path, package_json_path, field)?; + paths.insert(path.clone()); + Ok(()) + } + serde_json::Value::Object(map) => { + for (key, value) in map { + collect_export_paths(value, package_json_path, &format!("{field}.{key}"), paths)?; + } + Ok(()) + } + _ => Err(format!( + "package.json {field} must name file paths: {}", + package_json_path.display() + )), + } +} + +fn validate_package_surface_path( + value: &str, + package_json_path: &Path, + field: &str, +) -> Result<(), String> { + if value.trim().is_empty() + || value.trim() != value + || !value.starts_with("./dist/") + || value.contains('\\') + || value.split('/').any(|segment| segment == "..") + { + return Err(format!( + "package.json {field} must be a relative dist path: {}", + package_json_path.display() + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use crate::package_matrix::{WasmPackageSpec, validate_package_matrix}; + + use super::{ + check_binding_crate_sources, check_no_typescript_files, check_wasm_package_surface, + }; + + #[test] + fn package_skeleton_is_valid() { + validate_package_matrix().expect("package matrix validates"); + } + + #[test] + fn rejects_crate_typescript_directories() { + let root = test_root("typescript_dir"); + let typescript_dir = root + .join("crates") + .join("core_bindings") + .join("src") + .join("typescript"); + fs::create_dir_all(&typescript_dir).expect("create forbidden directory"); + + let error = check_binding_crate_sources(&root).expect_err("forbidden directory rejected"); + + assert!(error.contains("forbidden crate TypeScript source directory")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_crate_typescript_files() { + let root = test_root("typescript_file"); + let src_dir = root.join("crates/core_bindings/src"); + fs::create_dir_all(&src_dir).expect("create crate source directory"); + fs::write(src_dir.join("types.ts"), "export type A = string;\n") + .expect("write forbidden file"); + + let error = check_no_typescript_files(&src_dir).expect_err("forbidden file rejected"); + + assert!(error.contains("forbidden crate TypeScript source file")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn wasm_package_surface_requires_exported_dist_files() { + let root = test_root("wasm_surface"); + let package_dir = root.join("packages").join("example-wasm"); + fs::create_dir_all(package_dir.join("dist")).expect("create dist"); + fs::write( + package_dir.join("package.json"), + r#"{ + "name": "@radroots/example-wasm", + "private": true, + "main": "./dist/example.js", + "types": "./dist/example.d.ts", + "exports": { + ".": { + "types": "./dist/example.d.ts", + "import": "./dist/example.js", + "default": "./dist/example.js" + } + } +}"#, + ) + .expect("write package json"); + fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); + let spec = WasmPackageSpec { + key: "example", + crate_name: "radroots_example_wasm", + crate_dir: "crates/example_wasm", + package_name: "@radroots/example-wasm", + package_dir: "packages/example-wasm", + out_name: "example", + out_dir: "../../packages/example-wasm/dist", + }; + + let missing = + check_wasm_package_surface(&root, spec).expect_err("missing d.ts should fail"); + assert!(missing.contains("example.d.ts")); + fs::write( + package_dir.join("dist").join("example.d.ts"), + "export {};\n", + ) + .expect("write d.ts"); + check_wasm_package_surface(&root, spec).expect("surface is complete"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn wasm_package_surface_rejects_generated_package_manifest() { + let root = test_root("wasm_dist_package_manifest"); + let package_dir = root.join("packages").join("example-wasm"); + fs::create_dir_all(package_dir.join("dist")).expect("create dist"); + fs::write( + package_dir.join("package.json"), + r#"{ + "name": "@radroots/example-wasm", + "private": true, + "main": "./dist/example.js", + "types": "./dist/example.d.ts", + "exports": "./dist/example.js" +}"#, + ) + .expect("write package json"); + fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); + fs::write( + package_dir.join("dist").join("example.d.ts"), + "export {};\n", + ) + .expect("write d.ts"); + fs::write(package_dir.join("dist").join("package.json"), "{}\n") + .expect("write forbidden manifest"); + let spec = WasmPackageSpec { + key: "example", + crate_name: "radroots_example_wasm", + crate_dir: "crates/example_wasm", + package_name: "@radroots/example-wasm", + package_dir: "packages/example-wasm", + out_name: "example", + out_dir: "../../packages/example-wasm/dist", + }; + + let error = + check_wasm_package_surface(&root, spec).expect_err("dist package manifest rejected"); + assert!(error.contains("generated package manifest is forbidden")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn wasm_package_surface_rejects_subpath_exports() { + let root = test_root("wasm_subpath_exports"); + let package_dir = root.join("packages").join("example-wasm"); + fs::create_dir_all(package_dir.join("dist")).expect("create dist"); + fs::write( + package_dir.join("package.json"), + r#"{ + "name": "@radroots/example-wasm", + "private": true, + "main": "./dist/example.js", + "types": "./dist/example.d.ts", + "exports": { + ".": "./dist/example.js", + "./extra": "./dist/extra.js" + } +}"#, + ) + .expect("write package json"); + fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); + fs::write( + package_dir.join("dist").join("example.d.ts"), + "export {};\n", + ) + .expect("write d.ts"); + let spec = WasmPackageSpec { + key: "example", + crate_name: "radroots_example_wasm", + crate_dir: "crates/example_wasm", + package_name: "@radroots/example-wasm", + package_dir: "packages/example-wasm", + out_name: "example", + out_dir: "../../packages/example-wasm/dist", + }; + + let error = check_wasm_package_surface(&root, spec).expect_err("subpath export rejected"); + assert!(error.contains("only supports root exports")); + + let _ = fs::remove_dir_all(root); + } + + fn test_root(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "radroots_sdk_xtask_check_{name}_{}_{}", + std::process::id(), + stamp + )); + let _ = fs::remove_dir_all(&root); + root + } +} diff --git a/tools/sdk_xtask_import/src/contracts.rs b/tools/sdk_xtask_import/src/contracts.rs @@ -0,0 +1,411 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExportContract { + language: LanguageContract, + packages: BTreeMap<String, String>, + artifacts: Option<ExportArtifacts>, + runtime: RuntimeContract, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PackageContract { + language: LanguageContract, + sdk: SdkPackageContract, + rollout: RolloutContract, + operations: BTreeMap<String, String>, + shared_types: BTreeMap<String, String>, + artifacts: Option<SdkArtifacts>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LanguageContract { + id: String, + repository: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeContract { + networking: String, + signing: String, + deterministic_codec: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExportArtifacts { + models_dir: String, + constants_dir: String, + wasm_dist_dir: Option<String>, + manifest_file: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SdkPackageContract { + package: String, + module_format: Option<String>, + deterministic_codec: String, + signing: String, + networking: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RolloutContract { + stage: String, + order: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SdkArtifacts { + models_dir: String, + runtime_dir: String, + wasm_dist_dir: String, + manifest_file: String, +} + +pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> { + let exports = load_contract_dir::<ExportContract>(&root.join("contracts").join("exports"))?; + let packages = load_contract_dir::<PackageContract>(&root.join("contracts").join("packages"))?; + if exports.is_empty() { + return Err("contracts/exports must define at least one language".to_owned()); + } + if packages.is_empty() { + return Err("contracts/packages must define at least one language".to_owned()); + } + + let mut export_packages = BTreeMap::new(); + let mut export_languages = BTreeSet::new(); + for export in &exports { + validate_language(&export.language, "exports")?; + validate_non_empty_map(&export.packages, "exports packages")?; + validate_runtime( + &export.runtime.networking, + &export.runtime.signing, + &export.runtime.deterministic_codec, + &format!("exports {}", export.language.id), + )?; + let artifacts = export + .artifacts + .as_ref() + .ok_or_else(|| format!("exports {} artifacts are required", export.language.id))?; + validate_non_empty(&artifacts.models_dir, "exports artifacts.models_dir")?; + validate_non_empty(&artifacts.constants_dir, "exports artifacts.constants_dir")?; + validate_non_empty(&artifacts.manifest_file, "exports artifacts.manifest_file")?; + if export.language.id == "ts" { + validate_non_empty( + artifacts.wasm_dist_dir.as_deref().unwrap_or(""), + "exports ts artifacts.wasm_dist_dir", + )?; + } + if !export_languages.insert(export.language.id.clone()) { + return Err(format!("duplicate exports language {}", export.language.id)); + } + let packages = export + .packages + .values() + .cloned() + .collect::<BTreeSet<String>>(); + if packages.len() != 1 { + return Err(format!( + "exports {} must resolve to one curated package", + export.language.id + )); + } + export_packages.insert(export.language.id.clone(), packages); + } + + let mut package_languages = BTreeSet::new(); + let mut operation_keys: Option<BTreeSet<String>> = None; + let mut shared_type_keys: Option<BTreeSet<String>> = None; + let mut rollout_orders = BTreeMap::new(); + for package in &packages { + validate_language(&package.language, "packages")?; + validate_non_empty(&package.sdk.package, "packages sdk.package")?; + validate_runtime( + &package.sdk.networking, + &package.sdk.signing, + &package.sdk.deterministic_codec, + &format!("packages {}", package.language.id), + )?; + if let Some(module_format) = package.sdk.module_format.as_deref() { + validate_non_empty(module_format, "packages sdk.module_format")?; + } + validate_rollout(&package.language.id, &package.rollout)?; + validate_non_empty_map(&package.operations, "packages operations")?; + validate_non_empty_map(&package.shared_types, "packages shared_types")?; + if package.language.id == "ts" { + let artifacts = package + .artifacts + .as_ref() + .ok_or_else(|| "packages ts artifacts are required".to_owned())?; + validate_non_empty(&artifacts.models_dir, "packages ts artifacts.models_dir")?; + validate_non_empty(&artifacts.runtime_dir, "packages ts artifacts.runtime_dir")?; + validate_non_empty( + &artifacts.wasm_dist_dir, + "packages ts artifacts.wasm_dist_dir", + )?; + validate_non_empty( + &artifacts.manifest_file, + "packages ts artifacts.manifest_file", + )?; + } + if !package_languages.insert(package.language.id.clone()) { + return Err(format!( + "duplicate packages language {}", + package.language.id + )); + } + let Some(packages_for_language) = export_packages.get(&package.language.id) else { + return Err(format!( + "packages {} is missing a matching export contract", + package.language.id + )); + }; + let expected = [package.sdk.package.clone()] + .into_iter() + .collect::<BTreeSet<_>>(); + if packages_for_language != &expected { + return Err(format!( + "exports {} must resolve to package {}", + package.language.id, package.sdk.package + )); + } + let current_operations = package.operations.keys().cloned().collect::<BTreeSet<_>>(); + match &operation_keys { + Some(expected) if expected != &current_operations => { + return Err(format!( + "packages {} operations must match the shared operation set", + package.language.id + )); + } + None => operation_keys = Some(current_operations), + _ => {} + } + let current_shared_types = package + .shared_types + .keys() + .cloned() + .collect::<BTreeSet<_>>(); + match &shared_type_keys { + Some(expected) if expected != &current_shared_types => { + return Err(format!( + "packages {} shared_types must match the shared type set", + package.language.id + )); + } + None => shared_type_keys = Some(current_shared_types), + _ => {} + } + rollout_orders.insert(package.language.id.clone(), package.rollout.order); + } + + if export_languages != package_languages { + return Err("contracts/exports and contracts/packages languages must match".to_owned()); + } + if rollout_orders.get("ts") != Some(&1) { + return Err("packages ts rollout.order must be 1".to_owned()); + } + Ok(()) +} + +fn load_contract_dir<T>(dir: &Path) -> Result<Vec<T>, String> +where + T: for<'de> Deserialize<'de>, +{ + let read_dir = + fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?; + let mut entries = read_dir + .collect::<Result<Vec<_>, _>>() + .map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; + entries.sort_by_key(|entry| entry.file_name()); + let mut contracts = Vec::new(); + for entry in entries { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("toml") { + continue; + } + contracts.push(parse_toml(&path)?); + } + Ok(contracts) +} + +fn parse_toml<T>(path: &PathBuf) -> Result<T, String> +where + T: for<'de> Deserialize<'de>, +{ + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) +} + +fn validate_language(language: &LanguageContract, family: &str) -> Result<(), String> { + validate_non_empty(&language.id, &format!("{family} language.id"))?; + validate_non_empty( + &language.repository, + &format!("{family} language.repository"), + ) +} + +fn validate_runtime( + networking: &str, + signing: &str, + deterministic_codec: &str, + family: &str, +) -> Result<(), String> { + validate_non_empty(networking, &format!("{family} networking"))?; + validate_non_empty(signing, &format!("{family} signing"))?; + validate_non_empty( + deterministic_codec, + &format!("{family} deterministic_codec"), + ) +} + +fn validate_rollout(language: &str, rollout: &RolloutContract) -> Result<(), String> { + validate_non_empty(&rollout.stage, "packages rollout.stage")?; + if !matches!(rollout.stage.as_str(), "active" | "next" | "deferred") { + return Err(format!("packages {language} rollout.stage is invalid")); + } + if rollout.order == 0 { + return Err(format!( + "packages {language} rollout.order must be greater than zero" + )); + } + Ok(()) +} + +fn validate_non_empty(value: &str, field: &str) -> Result<(), String> { + if value.trim().is_empty() || value.trim() != value { + return Err(format!("{field} must be non-empty")); + } + Ok(()) +} + +fn validate_non_empty_map(map: &BTreeMap<String, String>, field: &str) -> Result<(), String> { + if map.is_empty() { + return Err(format!("{field} must not be empty")); + } + for (key, value) in map { + validate_non_empty(key, field)?; + validate_non_empty(value, field)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::validate_sdk_contracts; + + #[test] + fn current_sdk_contracts_validate() { + let root = crate::fs::workspace_root().expect("workspace root"); + validate_sdk_contracts(&root).expect("sdk contracts validate"); + } + + #[test] + fn rejects_mismatched_language_sets() { + let root = test_root("language_mismatch"); + write_contract( + &root, + "contracts/exports/ts.toml", + EXPORT_TS.replace("@radroots/sdk", "@radroots/sdk").as_str(), + ); + let error = validate_sdk_contracts(&root).expect_err("missing packages should fail"); + assert!(error.contains("contracts/packages")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_package_export_mismatch() { + let root = test_root("package_mismatch"); + write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); + write_contract( + &root, + "contracts/packages/ts.toml", + PACKAGE_TS + .replace("@radroots/sdk", "@radroots/other") + .as_str(), + ); + let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); + assert!(error.contains("exports ts must resolve")); + let _ = fs::remove_dir_all(root); + } + + fn test_root(name: &str) -> std::path::PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + std::env::temp_dir().join(format!("radroots_sdk_contracts_{name}_{stamp}")) + } + + fn write_contract(root: &std::path::Path, relative: &str, contents: &str) { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + fs::write(path, contents).expect("write contract"); + } + + const EXPORT_TS: &str = r#"[language] +id = "ts" +repository = "sdk-typescript" + +[packages] +"radroots_core" = "@radroots/sdk" + +[artifacts] +models_dir = "src/generated" +constants_dir = "src/generated" +wasm_dist_dir = "dist" +manifest_file = "export-manifest.json" + +[runtime] +networking = "native" +signing = "native" +deterministic_codec = "wasm" +"#; + + const PACKAGE_TS: &str = r#"[language] +id = "ts" +repository = "sdk-typescript" + +[sdk] +package = "@radroots/sdk" +module_format = "esm" +deterministic_codec = "wasm" +signing = "native" +networking = "native" + +[rollout] +stage = "active" +order = 1 + +[operations] +"profile.build_draft" = "profile.buildDraft" + +[shared_types] +"WireEventParts" = "WireEventParts" + +[artifacts] +models_dir = "src/generated" +runtime_dir = "src/runtime" +wasm_dist_dir = "dist" +manifest_file = "export-manifest.json" +"#; +} diff --git a/tools/sdk_xtask_import/src/fs.rs b/tools/sdk_xtask_import/src/fs.rs @@ -0,0 +1,46 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub fn workspace_root() -> Result<PathBuf, String> { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .parent() + .and_then(Path::parent) + .map(Path::to_path_buf) + .ok_or_else(|| { + format!( + "cannot resolve workspace root from {}", + manifest_dir.display() + ) + }) +} + +#[allow(dead_code)] +pub fn write_if_changed(path: &Path, contents: &str) -> Result<bool, String> { + if let Ok(existing) = fs::read_to_string(path) { + if existing == contents { + return Ok(false); + } + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + fs::write(path, contents) + .map_err(|error| format!("failed to write {}: {error}", path.display()))?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::workspace_root; + + #[test] + fn resolves_workspace_root() { + let root = workspace_root().expect("workspace root resolves"); + assert!(root.join("Cargo.toml").is_file()); + assert!(root.join("packages").is_dir()); + } +} diff --git a/tools/sdk_xtask_import/src/generate.rs b/tools/sdk_xtask_import/src/generate.rs @@ -0,0 +1,16 @@ +use crate::{fs::workspace_root, output::package_outputs, package_matrix::validate_package_matrix}; + +pub fn generate_ts() -> Result<(), String> { + validate_package_matrix()?; + let root = workspace_root()?; + for output in package_outputs() { + for generated_file in output.files() { + let path = root + .join(output.spec.package_dir) + .join(generated_file.relative_path); + crate::fs::write_if_changed(&path, &generated_file.contents)?; + } + println!("generated TypeScript package {}", output.spec.package_name); + } + Ok(()) +} diff --git a/tools/sdk_xtask_import/src/main.rs b/tools/sdk_xtask_import/src/main.rs @@ -0,0 +1,88 @@ +mod check; +mod contracts; +mod fs; +mod generate; +mod manifest; +mod output; +mod package_matrix; +mod ts; +mod wasm; + +enum CommandAction<'a> { + GenerateTs, + GenerateWasm(&'a [String]), + Check, +} + +fn main() { + if let Err(error) = run(std::env::args().skip(1)) { + eprintln!("{error}"); + std::process::exit(1); + } +} + +fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> { + let args = args.into_iter().collect::<Vec<_>>(); + match command_action(&args)? { + CommandAction::GenerateTs => generate::generate_ts(), + CommandAction::GenerateWasm(rest) => wasm::generate(rest), + CommandAction::Check => check::check(), + } +} + +fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { + match args { + [command, target] if command == "generate" && target == "ts" => { + Ok(CommandAction::GenerateTs) + } + [command, target, rest @ ..] if command == "generate" && target == "wasm" => { + Ok(CommandAction::GenerateWasm(rest)) + } + [command] if command == "check" => Ok(CommandAction::Check), + [] => Err(usage()), + _ => Err(usage()), + } +} + +fn usage() -> String { + "usage: cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask check" + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::{CommandAction, command_action}; + + #[test] + fn accepts_generate_ts() { + let args = ["generate".to_owned(), "ts".to_owned()]; + assert!(matches!( + command_action(&args).expect("action"), + CommandAction::GenerateTs + )); + } + + #[test] + fn accepts_generate_wasm() { + let args = ["generate".to_owned(), "wasm".to_owned()]; + assert!(matches!( + command_action(&args).expect("action"), + CommandAction::GenerateWasm(rest) if rest.is_empty() + )); + } + + #[test] + fn accepts_check() { + let args = ["check".to_owned()]; + assert!(matches!( + command_action(&args).expect("action"), + CommandAction::Check + )); + } + + #[test] + fn rejects_unknown_command() { + let args = ["generate".to_owned(), "swift".to_owned()]; + assert!(command_action(&args).is_err()); + } +} diff --git a/tools/sdk_xtask_import/src/manifest.rs b/tools/sdk_xtask_import/src/manifest.rs @@ -0,0 +1,36 @@ +use serde_json::json; + +use crate::package_matrix::PackageSpec; + +pub fn manifest_file_name() -> &'static str { + "sdk-manifest.json" +} + +pub fn package_manifest(spec: PackageSpec) -> serde_json::Value { + json!({ + "package": spec.package_name, + "crate": spec.crate_name, + "generator": "radroots_sdk_xtask", + "generated": false + }) +} + +#[cfg(test)] +mod tests { + use crate::{ + manifest::{manifest_file_name, package_manifest}, + package_matrix::package_specs, + }; + + #[test] + fn manifest_name_is_stable() { + assert_eq!(manifest_file_name(), "sdk-manifest.json"); + } + + #[test] + fn manifest_records_package_and_crate() { + let manifest = package_manifest(package_specs()[0]); + assert_eq!(manifest["package"], package_specs()[0].package_name); + assert_eq!(manifest["crate"], package_specs()[0].crate_name); + } +} diff --git a/tools/sdk_xtask_import/src/output.rs b/tools/sdk_xtask_import/src/output.rs @@ -0,0 +1,269 @@ +use crate::{ + manifest::manifest_file_name, + manifest::package_manifest, + package_matrix::{PackageSpec, package_specs}, + ts::{generated_constants_file, generated_header, generated_kinds_file, generated_types_file}, +}; +use radroots_sdk_binding_model::TsModule; + +pub struct PackageOutput { + pub spec: PackageSpec, + pub types_ts: Option<TsSource>, + pub types_imports_ts: Option<&'static str>, + pub constants_ts: Option<TsSource>, + pub kinds_ts: Option<TsSource>, +} + +pub struct GeneratedFile { + pub relative_path: String, + pub contents: String, +} + +pub enum TsSource { + Module(TsModule), +} + +impl TsSource { + fn render(&self) -> String { + match self { + Self::Module(module) => module.render(), + } + } +} + +impl PackageOutput { + pub fn files(&self) -> Vec<GeneratedFile> { + let mut files = Vec::new(); + if let Some(types_ts) = &self.types_ts { + files.push(GeneratedFile { + relative_path: format!("src/generated/{}", generated_types_file()), + contents: render_ts(types_ts, self.types_imports_ts), + }); + } + if let Some(constants_ts) = &self.constants_ts { + files.push(GeneratedFile { + relative_path: format!("src/generated/{}", generated_constants_file()), + contents: render_ts(constants_ts, None), + }); + } + if let Some(kinds_ts) = &self.kinds_ts { + files.push(GeneratedFile { + relative_path: format!("src/generated/{}", generated_kinds_file()), + contents: render_ts(kinds_ts, None), + }); + } + files.push(GeneratedFile { + relative_path: format!("src/generated/{}", manifest_file_name()), + contents: render_manifest(self.spec), + }); + files.push(GeneratedFile { + relative_path: "src/index.ts".to_owned(), + contents: render_index(self), + }); + files + } +} + +pub fn package_outputs() -> Vec<PackageOutput> { + vec![ + PackageOutput { + spec: spec_by_key("core"), + types_ts: Some(TsSource::Module(radroots_core_bindings::types_module())), + types_imports_ts: None, + constants_ts: None, + kinds_ts: None, + }, + PackageOutput { + spec: spec_by_key("events"), + types_ts: Some(TsSource::Module(radroots_events_bindings::types_module())), + types_imports_ts: Some(EVENTS_TYPES_IMPORTS_TS), + constants_ts: Some(TsSource::Module( + radroots_events_bindings::constants_module(), + )), + kinds_ts: Some(TsSource::Module(radroots_events_bindings::kinds_module())), + }, + PackageOutput { + spec: spec_by_key("events_indexed"), + types_ts: Some(TsSource::Module( + radroots_events_indexed_bindings::types_module(), + )), + types_imports_ts: None, + constants_ts: None, + kinds_ts: None, + }, + PackageOutput { + spec: spec_by_key("identity"), + types_ts: None, + types_imports_ts: None, + constants_ts: Some(TsSource::Module( + radroots_identity_bindings::constants_module(), + )), + kinds_ts: None, + }, + PackageOutput { + spec: spec_by_key("replica_db_schema"), + types_ts: Some(TsSource::Module( + radroots_replica_db_schema_bindings::types_module(), + )), + types_imports_ts: Some(REPLICA_DB_SCHEMA_TYPES_IMPORTS_TS), + constants_ts: None, + kinds_ts: None, + }, + PackageOutput { + spec: spec_by_key("trade"), + types_ts: Some(TsSource::Module(radroots_trade_bindings::types_module())), + types_imports_ts: Some(TRADE_TYPES_IMPORTS_TS), + constants_ts: None, + kinds_ts: None, + }, + PackageOutput { + spec: spec_by_key("types"), + types_ts: Some(TsSource::Module(radroots_types_bindings::types_module())), + types_imports_ts: None, + constants_ts: None, + kinds_ts: None, + }, + ] +} + +fn spec_by_key(key: &str) -> PackageSpec { + package_specs() + .iter() + .copied() + .find(|spec| spec.key == key) + .unwrap_or_else(|| panic!("missing package spec for {key}")) +} + +fn render_ts(source: &TsSource, imports: Option<&str>) -> String { + let body = source.render(); + let imports = imports.unwrap_or(""); + let mut rendered = format!("{}{}{}", generated_header(), imports, body.trim_start()); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered +} + +const EVENTS_TYPES_IMPORTS_TS: &str = r#"import type { + RadrootsCoreCurrency, + RadrootsCoreDecimal, + RadrootsCoreDiscount, + RadrootsCoreDiscountValue, + RadrootsCoreMoney, + RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, + RadrootsCoreUnit, +} from "@radroots/core-bindings"; + +"#; + +const REPLICA_DB_SCHEMA_TYPES_IMPORTS_TS: &str = r#"import type { + IResult, + IResultList, + IResultPass, +} from "@radroots/types-bindings"; + +"#; + +const TRADE_TYPES_IMPORTS_TS: &str = r#"import type { + RadrootsCoreCurrency, + RadrootsCoreDecimal, + RadrootsCoreDiscount, + RadrootsCoreDiscountValue, + RadrootsCoreMoney, + RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, + RadrootsCoreUnit, +} from "@radroots/core-bindings"; +import type { + RadrootsListingImage, + RadrootsNostrEventPtr, + RadrootsPlotRef, + RadrootsResourceAreaRef, + RadrootsTradeMessagePayload, + RadrootsTradeOrderEconomicLine, + RadrootsTradeOrderItem, +} from "@radroots/events-bindings"; + +"#; + +fn render_manifest(spec: PackageSpec) -> String { + let mut value = package_manifest(spec); + value["generated"] = serde_json::Value::Bool(true); + format!( + "{}\n", + serde_json::to_string_pretty(&value).expect("manifest json serializes") + ) +} + +fn render_index(output: &PackageOutput) -> String { + let mut lines = Vec::new(); + if output.types_ts.is_some() { + lines.push("export * from \"./generated/types.js\";"); + } + if output.constants_ts.is_some() { + lines.push("export * from \"./generated/constants.js\";"); + } + if output.kinds_ts.is_some() { + lines.push("export * from \"./generated/kinds.js\";"); + } + if lines.is_empty() { + lines.push("export {};"); + } + format!("{}\n", lines.join("\n")) +} + +#[cfg(test)] +mod tests { + use super::{TsSource, package_outputs, render_ts}; + use radroots_sdk_binding_model::{module, string, type_alias}; + + #[test] + fn renders_sdk_header() { + let output = render_ts( + &TsSource::Module(module(vec![type_alias("A", string())])), + None, + ); + assert!(output.starts_with("// @generated by cargo xtask generate ts")); + assert!(output.contains("export type A = string;")); + } + + #[test] + fn renders_import_prelude_after_header() { + let output = render_ts( + &TsSource::Module(module(vec![type_alias("A", string())])), + Some("import type { B } from \"b\";\n\n"), + ); + assert!(output.starts_with( + "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type" + )); + assert!(output.contains("export type A = string;")); + } + + #[test] + fn renders_model_sources() { + let output = render_ts( + &TsSource::Module(module(vec![type_alias("A", string())])), + None, + ); + assert_eq!( + output, + "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nexport type A = string;\n" + ); + } + + #[test] + fn includes_core_and_types_outputs() { + let package_names = package_outputs() + .into_iter() + .map(|output| output.spec.package_name) + .collect::<Vec<_>>(); + assert!(package_names.contains(&"@radroots/core-bindings")); + assert!(package_names.contains(&"@radroots/events-bindings")); + assert!(package_names.contains(&"@radroots/events-indexed-bindings")); + assert!(package_names.contains(&"@radroots/identity-bindings")); + assert!(package_names.contains(&"@radroots/replica-db-schema-bindings")); + assert!(package_names.contains(&"@radroots/trade-bindings")); + assert!(package_names.contains(&"@radroots/types-bindings")); + } +} diff --git a/tools/sdk_xtask_import/src/package_matrix.rs b/tools/sdk_xtask_import/src/package_matrix.rs @@ -0,0 +1,213 @@ +use std::collections::BTreeSet; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PackageSpec { + pub key: &'static str, + pub crate_name: &'static str, + pub crate_dir: &'static str, + pub package_name: &'static str, + pub package_dir: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WasmPackageSpec { + pub key: &'static str, + pub crate_name: &'static str, + pub crate_dir: &'static str, + pub package_name: &'static str, + pub package_dir: &'static str, + pub out_name: &'static str, + pub out_dir: &'static str, +} + +pub const PACKAGE_SPECS: [PackageSpec; 7] = [ + PackageSpec { + key: "core", + crate_name: "radroots_core_bindings", + crate_dir: "crates/core_bindings", + package_name: "@radroots/core-bindings", + package_dir: "packages/core-bindings", + }, + PackageSpec { + key: "events", + crate_name: "radroots_events_bindings", + crate_dir: "crates/events_bindings", + package_name: "@radroots/events-bindings", + package_dir: "packages/events-bindings", + }, + PackageSpec { + key: "events_indexed", + crate_name: "radroots_events_indexed_bindings", + crate_dir: "crates/events_indexed_bindings", + package_name: "@radroots/events-indexed-bindings", + package_dir: "packages/events-indexed-bindings", + }, + PackageSpec { + key: "identity", + crate_name: "radroots_identity_bindings", + crate_dir: "crates/identity_bindings", + package_name: "@radroots/identity-bindings", + package_dir: "packages/identity-bindings", + }, + PackageSpec { + key: "replica_db_schema", + crate_name: "radroots_replica_db_schema_bindings", + crate_dir: "crates/replica_db_schema_bindings", + package_name: "@radroots/replica-db-schema-bindings", + package_dir: "packages/replica-db-schema-bindings", + }, + PackageSpec { + key: "trade", + crate_name: "radroots_trade_bindings", + crate_dir: "crates/trade_bindings", + package_name: "@radroots/trade-bindings", + package_dir: "packages/trade-bindings", + }, + PackageSpec { + key: "types", + crate_name: "radroots_types_bindings", + crate_dir: "crates/types_bindings", + package_name: "@radroots/types-bindings", + package_dir: "packages/types-bindings", + }, +]; + +pub const WASM_PACKAGE_SPECS: [WasmPackageSpec; 3] = [ + WasmPackageSpec { + key: "events_codec", + crate_name: "radroots_events_codec_wasm", + crate_dir: "crates/events_codec_wasm", + package_name: "@radroots/events-codec-wasm", + package_dir: "packages/events-codec-wasm", + out_name: "radroots_events_codec_wasm", + out_dir: "../../packages/events-codec-wasm/dist", + }, + WasmPackageSpec { + key: "replica_db", + crate_name: "radroots_replica_db_wasm", + crate_dir: "crates/replica_db_wasm", + package_name: "@radroots/replica-db-wasm", + package_dir: "packages/replica-db-wasm", + out_name: "radroots_replica_db_wasm", + out_dir: "../../packages/replica-db-wasm/dist", + }, + WasmPackageSpec { + key: "replica_sync", + crate_name: "radroots_replica_sync_wasm", + crate_dir: "crates/replica_sync_wasm", + package_name: "@radroots/replica-sync-wasm", + package_dir: "packages/replica-sync-wasm", + out_name: "radroots_replica_sync_wasm", + out_dir: "../../packages/replica-sync-wasm/dist", + }, +]; + +pub const FORBIDDEN_PACKAGE_NAMES: [&str; 2] = + ["@radroots/tangle-db-schema-bindings", "@radroots/contracts"]; + +pub fn package_specs() -> &'static [PackageSpec] { + &PACKAGE_SPECS +} + +pub fn wasm_package_specs() -> &'static [WasmPackageSpec] { + &WASM_PACKAGE_SPECS +} + +pub fn validate_package_matrix() -> Result<(), String> { + let mut crate_names = BTreeSet::new(); + let mut package_names = BTreeSet::new(); + let mut package_dirs = BTreeSet::new(); + for spec in package_specs() { + if FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name) { + return Err(format!( + "forbidden package in matrix: {}", + spec.package_name + )); + } + if !crate_names.insert(spec.crate_name) { + return Err(format!("duplicate crate in matrix: {}", spec.crate_name)); + } + if !package_names.insert(spec.package_name) { + return Err(format!( + "duplicate package in matrix: {}", + spec.package_name + )); + } + if !package_dirs.insert(spec.package_dir) { + return Err(format!( + "duplicate package directory in matrix: {}", + spec.package_dir + )); + } + } + for spec in wasm_package_specs() { + if FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name) { + return Err(format!( + "forbidden package in matrix: {}", + spec.package_name + )); + } + if !crate_names.insert(spec.crate_name) { + return Err(format!("duplicate crate in matrix: {}", spec.crate_name)); + } + if !package_names.insert(spec.package_name) { + return Err(format!( + "duplicate package in matrix: {}", + spec.package_name + )); + } + if !package_dirs.insert(spec.package_dir) { + return Err(format!( + "duplicate package directory in matrix: {}", + spec.package_dir + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix, wasm_package_specs, + }; + + #[test] + fn package_matrix_is_valid() { + validate_package_matrix().expect("package matrix is valid"); + } + + #[test] + fn approved_package_count_is_stable() { + assert_eq!(package_specs().len(), 7); + assert_eq!(wasm_package_specs().len(), 3); + } + + #[test] + fn forbidden_names_are_absent() { + for spec in package_specs() { + assert!(!FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name)); + } + for spec in wasm_package_specs() { + assert!(!FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name)); + } + } + + #[test] + fn replica_schema_package_uses_current_name() { + assert!( + package_specs() + .iter() + .any(|spec| spec.package_name == "@radroots/replica-db-schema-bindings") + ); + } + + #[test] + fn wasm_packages_use_sdk_package_names() { + assert!( + wasm_package_specs() + .iter() + .any(|spec| spec.package_name == "@radroots/replica-db-wasm") + ); + } +} diff --git a/tools/sdk_xtask_import/src/ts.rs b/tools/sdk_xtask_import/src/ts.rs @@ -0,0 +1,37 @@ +pub fn generated_header() -> &'static str { + "// @generated by cargo xtask generate ts\n// Do not edit by hand.\n" +} + +pub fn generated_types_file() -> &'static str { + "types.ts" +} + +pub fn generated_constants_file() -> &'static str { + "constants.ts" +} + +pub fn generated_kinds_file() -> &'static str { + "kinds.ts" +} + +#[cfg(test)] +mod tests { + use super::{ + generated_constants_file, generated_header, generated_kinds_file, generated_types_file, + }; + + #[test] + fn generated_header_matches_contract() { + assert_eq!( + generated_header(), + "// @generated by cargo xtask generate ts\n// Do not edit by hand.\n" + ); + } + + #[test] + fn generated_file_names_are_stable() { + assert_eq!(generated_types_file(), "types.ts"); + assert_eq!(generated_constants_file(), "constants.ts"); + assert_eq!(generated_kinds_file(), "kinds.ts"); + } +} diff --git a/tools/sdk_xtask_import/src/wasm.rs b/tools/sdk_xtask_import/src/wasm.rs @@ -0,0 +1,328 @@ +use std::{ + collections::BTreeSet, + env, fs, + path::{Path, PathBuf}, + process::Command, +}; + +use crate::{ + check::check_wasm_package_surface, + fs::workspace_root, + package_matrix::{WasmPackageSpec, validate_package_matrix, wasm_package_specs}, +}; + +const WASM_TARGET: &str = "wasm32-unknown-unknown"; + +pub fn generate(args: &[String]) -> Result<(), String> { + validate_package_matrix()?; + let specs = selected_specs(args)?; + let root = workspace_root()?; + let toolchain = resolve_wasm_toolchain()?; + for spec in specs { + let dist_dir = root.join(spec.package_dir).join("dist"); + if dist_dir.exists() { + fs::remove_dir_all(&dist_dir) + .map_err(|error| format!("failed to remove {}: {error}", dist_dir.display()))?; + } + let mut command = Command::new(&toolchain.wasm_pack); + command.current_dir(&root); + for arg in wasm_pack_args(spec) { + command.arg(arg); + } + if let Some(parent) = toolchain.rustc.parent() { + prepend_path(&mut command, parent); + } + command.env("RUSTC", &toolchain.rustc); + command.env("CARGO", &toolchain.cargo); + let status = command.status().map_err(|error| { + format!( + "failed to start wasm-pack for {} while generating {}: {error}", + spec.key, spec.package_name + ) + })?; + if !status.success() { + return Err(format!( + "wasm-pack failed for {} while generating {} with status {status}; rerun `cargo xtask generate wasm --package {}` after fixing the wasm toolchain", + spec.key, spec.package_name, spec.key + )); + } + check_wasm_package_surface(&root, spec)?; + println!("generated wasm package {}", spec.package_name); + } + Ok(()) +} + +struct WasmToolchain { + wasm_pack: PathBuf, + rustc: PathBuf, + cargo: PathBuf, +} + +fn resolve_wasm_toolchain() -> Result<WasmToolchain, String> { + let wasm_pack = resolve_required_path_tool("wasm-pack")?; + let rustc = resolve_required_rust_tool("rustc", "RUSTC")?; + let cargo = resolve_required_rust_tool("cargo", "CARGO")?; + ensure_wasm_target_installed()?; + Ok(WasmToolchain { + wasm_pack, + rustc, + cargo, + }) +} + +fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> { + vec![ + "build", + spec.crate_dir, + "--release", + "--target", + "web", + "--out-dir", + spec.out_dir, + "--out-name", + spec.out_name, + "--no-pack", + ] +} + +fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> { + match args { + [] => Ok(wasm_package_specs().to_vec()), + [flag, key] if flag == "--package" => wasm_package_specs() + .iter() + .copied() + .find(|spec| spec.key == key) + .map(|spec| vec![spec]) + .ok_or_else(|| format!("unknown wasm package: {key}")), + _ => Err("usage: cargo xtask generate wasm [--package <key>]".to_owned()), + } +} + +fn resolve_required_path_tool(name: &str) -> Result<PathBuf, String> { + let path = env::var_os("PATH").ok_or_else(|| { + format!("missing {name}: PATH is not set; install {name} and expose it on PATH") + })?; + resolve_path_tool_from_path(name, &path) +} + +fn resolve_path_tool_from_path(name: &str, path: &std::ffi::OsStr) -> Result<PathBuf, String> { + let matches = executable_matches(name, path); + match matches.as_slice() { + [] => Err(format!( + "missing {name}: install {name} and rerun `cargo xtask generate wasm`" + )), + [tool] => Ok(tool.clone()), + _ => Err(format!( + "ambiguous {name}: found {}; remove duplicate {name} entries from PATH before running `cargo xtask generate wasm`", + matches + .iter() + .map(|path| path.display().to_string()) + .collect::<Vec<_>>() + .join(", ") + )), + } +} + +fn executable_matches(name: &str, path: &std::ffi::OsStr) -> Vec<PathBuf> { + let mut seen = BTreeSet::new(); + let mut matches = Vec::new(); + for dir in env::split_paths(path) { + let candidate = dir.join(name); + if !is_executable_file(&candidate) { + continue; + } + let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); + if seen.insert(key) { + matches.push(candidate); + } + } + matches +} + +fn is_executable_file(path: &Path) -> bool { + if !path.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + true + } +} + +fn resolve_required_rust_tool(name: &str, env_var: &str) -> Result<PathBuf, String> { + if let Some(path) = explicit_tool_path(env_var) { + return Ok(PathBuf::from(path)); + } + rustup_tool(name).ok_or_else(|| { + format!( + "missing rustup resolution for {name}: set {env_var} explicitly or install rustup with the {WASM_TARGET} target" + ) + }) +} + +fn explicit_tool_path(env_var: &str) -> Option<String> { + let value = env::var(env_var).ok()?; + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +fn rustup_tool(name: &str) -> Option<PathBuf> { + let output = Command::new("rustup") + .arg("which") + .arg(name) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8(output.stdout).ok()?; + let trimmed = path.trim(); + (!trimmed.is_empty()).then(|| PathBuf::from(trimmed)) +} + +fn ensure_wasm_target_installed() -> Result<(), String> { + let output = Command::new("rustup") + .arg("target") + .arg("list") + .arg("--installed") + .output() + .map_err(|error| { + format!( + "failed to verify {WASM_TARGET} target with rustup: {error}; install rustup or set RUSTC/CARGO from a toolchain that supports {WASM_TARGET}" + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "failed to verify {WASM_TARGET} target with rustup: {}; run `rustup target add {WASM_TARGET}`", + stderr.trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if !target_list_contains(&stdout, WASM_TARGET) { + return Err(format!( + "missing Rust target {WASM_TARGET}: run `rustup target add {WASM_TARGET}`" + )); + } + Ok(()) +} + +fn target_list_contains(output: &str, target: &str) -> bool { + output.lines().any(|line| line.trim() == target) +} + +fn prepend_path(command: &mut Command, prefix: &Path) { + let existing = env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![prefix.to_path_buf()]; + paths.extend(env::split_paths(&existing)); + if let Ok(joined) = env::join_paths(paths) { + command.env("PATH", joined); + } +} + +#[cfg(test)] +mod tests { + use std::{ + env, fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use crate::package_matrix::wasm_package_specs; + + use super::{ + resolve_path_tool_from_path, rustup_tool, selected_specs, target_list_contains, + wasm_pack_args, + }; + + #[test] + fn selects_all_specs_by_default() { + assert_eq!(selected_specs(&[]).expect("all specs").len(), 3); + } + + #[test] + fn selects_one_spec_by_key() { + let specs = selected_specs(&["--package".to_owned(), "replica_db".to_owned()]) + .expect("replica db spec"); + assert_eq!(specs[0].package_name, "@radroots/replica-db-wasm"); + } + + #[test] + fn rejects_unknown_spec_key() { + assert!(selected_specs(&["--package".to_owned(), "missing".to_owned()]).is_err()); + } + + #[test] + fn wasm_pack_arguments_disable_package_manifest_generation() { + let args = wasm_pack_args(wasm_package_specs()[0]); + assert!(args.contains(&"--no-pack")); + } + + #[test] + fn path_tool_resolution_reports_missing_tools() { + let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new("")) + .expect_err("missing"); + assert!(error.contains("missing wasm-pack")); + } + + #[test] + fn path_tool_resolution_reports_ambiguous_tools() { + let root = test_root("ambiguous_wasm_pack"); + let first = root.join("first"); + let second = root.join("second"); + fs::create_dir_all(&first).expect("create first dir"); + fs::create_dir_all(&second).expect("create second dir"); + write_executable(first.join("wasm-pack")); + write_executable(second.join("wasm-pack")); + let path = env::join_paths([first, second]).expect("join path"); + + let error = + resolve_path_tool_from_path("wasm-pack", &path).expect_err("ambiguous wasm-pack"); + + assert!(error.contains("ambiguous wasm-pack")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn target_list_parser_requires_exact_target() { + assert!(target_list_contains( + "aarch64-apple-darwin\nwasm32-unknown-unknown\n", + "wasm32-unknown-unknown" + )); + assert!(!target_list_contains( + "wasm32-unknown-emscripten\n", + "wasm32-unknown-unknown" + )); + } + + #[test] + fn rustup_tool_resolution_is_non_panicking() { + let _ = rustup_tool("rustc"); + } + + fn write_executable(path: PathBuf) { + fs::write(&path, "#!/bin/sh\n").expect("write executable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("set executable permissions"); + } + } + + fn test_root(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + env::temp_dir().join(format!("radroots_sdk_xtask_{name}_{stamp}")) + } +}