commit c7e992dcddadca60e04993110e8d3cdfe0cbb641
parent 6dad25fe523a1776a29e4a31b3fe416b21c4b37d
Author: triesap <tyson@radroots.org>
Date: Mon, 23 Feb 2026 16:54:13 +0000
xtask: add per-crate ts export command
- add sdk export-ts-crate with required --crate selector and optional --out path
- add selector-aware export helpers for models constants and wasm artifacts
- support selectors by crate id short crate name and @radroots package name
- run cargo check -q -p xtask and cargo test -q -p xtask
Diffstat:
2 files changed, 254 insertions(+), 9 deletions(-)
diff --git a/crates/xtask/src/export_ts.rs b/crates/xtask/src/export_ts.rs
@@ -43,6 +43,50 @@ fn ts_artifacts(mapping: &contract::ExportMapping) -> Result<&contract::ExportAr
.ok_or_else(|| "missing ts artifacts mapping".to_string())
}
+fn selected_package_entries<'a>(
+ mapping: &'a contract::ExportMapping,
+ selector: Option<&str>,
+) -> Result<Vec<(&'a String, &'a String)>, String> {
+ if let Some(selector) = selector {
+ if let Some(entry) = mapping.packages.get_key_value(selector) {
+ return Ok(vec![entry]);
+ }
+ if let Some(entry) = mapping
+ .packages
+ .iter()
+ .find(|(_, package_name)| package_name.as_str() == selector)
+ {
+ return Ok(vec![entry]);
+ }
+ if !selector.starts_with("radroots-") {
+ let crate_candidate = format!("radroots-{selector}");
+ if let Some(entry) = mapping.packages.get_key_value(&crate_candidate) {
+ return Ok(vec![entry]);
+ }
+ }
+ if !selector.starts_with("@radroots/") {
+ let package_candidate = format!("@radroots/{selector}");
+ if let Some(entry) = mapping
+ .packages
+ .iter()
+ .find(|(_, package_name)| package_name.as_str() == package_candidate)
+ {
+ return Ok(vec![entry]);
+ }
+ }
+ let known_crates = mapping
+ .packages
+ .keys()
+ .map(String::as_str)
+ .collect::<Vec<_>>()
+ .join(", ");
+ return Err(format!(
+ "unknown ts export crate selector {selector}; available crates: {known_crates}"
+ ));
+ }
+ Ok(mapping.packages.iter().collect())
+}
+
fn required_artifact_value<'a>(value: &'a Option<String>, field: &str) -> Result<&'a str, String> {
value
.as_deref()
@@ -146,10 +190,15 @@ fn collect_manifest_entries(
Ok(())
}
-pub fn export_ts_models(workspace_root: &Path, out_dir: &Path) -> Result<(), String> {
+fn export_ts_models_with_selector(
+ workspace_root: &Path,
+ out_dir: &Path,
+ selector: Option<&str>,
+) -> Result<(), String> {
let bundle = contract::load_contract_bundle(workspace_root)?;
contract::validate_contract_bundle(&bundle)?;
let ts_export = ts_export_mapping(&bundle)?;
+ let selected_entries = selected_package_entries(ts_export, selector)?;
let artifacts = ts_artifacts(ts_export)?;
let models_dir = required_artifact_value(&artifacts.models_dir, "models_dir")?;
let source_root = workspace_root.join("target").join("ts-rs");
@@ -160,9 +209,14 @@ pub fn export_ts_models(workspace_root: &Path, out_dir: &Path) -> Result<(), Str
));
}
let ts_out_root = out_dir.join("ts").join("packages");
+ let mut expected = 0usize;
let mut copied = 0usize;
- for (crate_name, package_name) in &ts_export.packages {
+ for (crate_name, package_name) in selected_entries {
let crate_dir = crate_name.strip_prefix("radroots-").unwrap_or(crate_name);
+ if crate_name.ends_with("-wasm") || !crate_supports_ts_rs(workspace_root, crate_dir)? {
+ continue;
+ }
+ expected += 1;
let src = source_root.join(crate_dir).join("types.ts");
let dst = to_package_dir(&ts_out_root, package_name)
.join(models_dir)
@@ -171,20 +225,37 @@ pub fn export_ts_models(workspace_root: &Path, out_dir: &Path) -> Result<(), Str
copied += 1;
}
}
- if copied == 0 {
+ if expected > 0 && copied == 0 {
return Err("no ts model files were exported".to_string());
}
Ok(())
}
-pub fn export_ts_constants(workspace_root: &Path, out_dir: &Path) -> Result<(), String> {
+pub fn export_ts_models(workspace_root: &Path, out_dir: &Path) -> Result<(), String> {
+ export_ts_models_with_selector(workspace_root, out_dir, None)
+}
+
+pub fn export_ts_models_for_crate(
+ workspace_root: &Path,
+ out_dir: &Path,
+ crate_selector: &str,
+) -> Result<(), String> {
+ export_ts_models_with_selector(workspace_root, out_dir, Some(crate_selector))
+}
+
+fn export_ts_constants_with_selector(
+ workspace_root: &Path,
+ out_dir: &Path,
+ selector: Option<&str>,
+) -> Result<(), String> {
let bundle = contract::load_contract_bundle(workspace_root)?;
contract::validate_contract_bundle(&bundle)?;
let ts_export = ts_export_mapping(&bundle)?;
+ let selected_entries = selected_package_entries(ts_export, selector)?;
let artifacts = ts_artifacts(ts_export)?;
let constants_dir = required_artifact_value(&artifacts.constants_dir, "constants_dir")?;
let ts_out_root = out_dir.join("ts").join("packages");
- for (crate_name, package_name) in &ts_export.packages {
+ for (crate_name, package_name) in selected_entries {
let crate_dir = crate_name.strip_prefix("radroots-").unwrap_or(crate_name);
let crate_root = workspace_root.join("crates").join(crate_dir);
for filename in ["constants.ts", "kinds.ts"] {
@@ -210,15 +281,32 @@ pub fn export_ts_constants(workspace_root: &Path, out_dir: &Path) -> Result<(),
Ok(())
}
-pub fn export_ts_wasm_artifacts(workspace_root: &Path, out_dir: &Path) -> Result<(), String> {
+pub fn export_ts_constants(workspace_root: &Path, out_dir: &Path) -> Result<(), String> {
+ export_ts_constants_with_selector(workspace_root, out_dir, None)
+}
+
+pub fn export_ts_constants_for_crate(
+ workspace_root: &Path,
+ out_dir: &Path,
+ crate_selector: &str,
+) -> Result<(), String> {
+ export_ts_constants_with_selector(workspace_root, out_dir, Some(crate_selector))
+}
+
+fn export_ts_wasm_artifacts_with_selector(
+ workspace_root: &Path,
+ out_dir: &Path,
+ selector: Option<&str>,
+) -> Result<(), String> {
let bundle = contract::load_contract_bundle(workspace_root)?;
contract::validate_contract_bundle(&bundle)?;
let ts_export = ts_export_mapping(&bundle)?;
+ let selected_entries = selected_package_entries(ts_export, selector)?;
let artifacts = ts_artifacts(ts_export)?;
let wasm_dist_dir = required_artifact_value(&artifacts.wasm_dist_dir, "wasm_dist_dir")?;
let ts_out_root = out_dir.join("ts").join("packages");
let mut copied = 0usize;
- for (crate_name, package_name) in &ts_export.packages {
+ for (crate_name, package_name) in selected_entries {
if !crate_name.ends_with("-wasm") {
continue;
}
@@ -237,6 +325,18 @@ pub fn export_ts_wasm_artifacts(workspace_root: &Path, out_dir: &Path) -> Result
Ok(())
}
+pub fn export_ts_wasm_artifacts(workspace_root: &Path, out_dir: &Path) -> Result<(), String> {
+ export_ts_wasm_artifacts_with_selector(workspace_root, out_dir, None)
+}
+
+pub fn export_ts_wasm_artifacts_for_crate(
+ workspace_root: &Path,
+ out_dir: &Path,
+ crate_selector: &str,
+) -> Result<(), String> {
+ export_ts_wasm_artifacts_with_selector(workspace_root, out_dir, Some(crate_selector))
+}
+
pub fn write_ts_export_manifest(workspace_root: &Path, out_dir: &Path) -> Result<PathBuf, String> {
let bundle = contract::load_contract_bundle(workspace_root)?;
contract::validate_contract_bundle(&bundle)?;
@@ -266,10 +366,14 @@ pub fn write_ts_export_manifest(workspace_root: &Path, out_dir: &Path) -> Result
Ok(manifest_path)
}
-pub fn generate_ts_rs_sources(workspace_root: &Path) -> Result<PathBuf, String> {
+fn generate_ts_rs_sources_with_selector(
+ workspace_root: &Path,
+ selector: Option<&str>,
+) -> Result<PathBuf, String> {
let bundle = contract::load_contract_bundle(workspace_root)?;
contract::validate_contract_bundle(&bundle)?;
let ts_export = ts_export_mapping(&bundle)?;
+ let selected_entries = selected_package_entries(ts_export, selector)?;
let source_root = workspace_root.join("target").join("ts-rs");
if source_root.exists() {
fs::remove_dir_all(&source_root)
@@ -277,8 +381,21 @@ pub fn generate_ts_rs_sources(workspace_root: &Path) -> Result<PathBuf, String>
}
fs::create_dir_all(&source_root)
.map_err(|e| format!("create {}: {e}", source_root.display()))?;
+ let mut expected = 0usize;
+ for (crate_name, _) in &selected_entries {
+ if crate_name.ends_with("-wasm") {
+ continue;
+ }
+ let crate_dir = crate_name.strip_prefix("radroots-").unwrap_or(crate_name);
+ if crate_supports_ts_rs(workspace_root, crate_dir)? {
+ expected += 1;
+ }
+ }
+ if expected == 0 {
+ return Ok(source_root);
+ }
let mut generated = 0usize;
- for (crate_name, package_name) in &ts_export.packages {
+ for (crate_name, package_name) in selected_entries {
if crate_name.ends_with("-wasm") {
continue;
}
@@ -314,6 +431,17 @@ pub fn generate_ts_rs_sources(workspace_root: &Path) -> Result<PathBuf, String>
Ok(source_root)
}
+pub fn generate_ts_rs_sources(workspace_root: &Path) -> Result<PathBuf, String> {
+ generate_ts_rs_sources_with_selector(workspace_root, None)
+}
+
+pub fn generate_ts_rs_sources_for_crate(
+ workspace_root: &Path,
+ crate_selector: &str,
+) -> Result<PathBuf, String> {
+ generate_ts_rs_sources_with_selector(workspace_root, Some(crate_selector))
+}
+
pub fn export_ts_bundle(workspace_root: &Path, out_dir: &Path) -> Result<PathBuf, String> {
generate_ts_rs_sources(workspace_root)?;
export_ts_models(workspace_root, out_dir)?;
@@ -322,9 +450,22 @@ pub fn export_ts_bundle(workspace_root: &Path, out_dir: &Path) -> Result<PathBuf
write_ts_export_manifest(workspace_root, out_dir)
}
+pub fn export_ts_bundle_for_crate(
+ workspace_root: &Path,
+ out_dir: &Path,
+ crate_selector: &str,
+) -> Result<PathBuf, String> {
+ generate_ts_rs_sources_for_crate(workspace_root, crate_selector)?;
+ export_ts_models_for_crate(workspace_root, out_dir, crate_selector)?;
+ export_ts_constants_for_crate(workspace_root, out_dir, crate_selector)?;
+ export_ts_wasm_artifacts_for_crate(workspace_root, out_dir, crate_selector)?;
+ write_ts_export_manifest(workspace_root, out_dir)
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ use std::collections::BTreeMap;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -349,6 +490,60 @@ mod tests {
std::env::temp_dir().join(format!("radroots_xtask_{prefix}_{ns}"))
}
+ fn test_ts_mapping() -> contract::ExportMapping {
+ let mut packages = BTreeMap::new();
+ packages.insert("radroots-core".to_string(), "@radroots/core".to_string());
+ packages.insert(
+ "radroots-events-codec-wasm".to_string(),
+ "@radroots/events-codec-wasm".to_string(),
+ );
+ contract::ExportMapping {
+ language: contract::ExportLanguage {
+ id: "ts".to_string(),
+ repository: "sdk-typescript".to_string(),
+ },
+ packages,
+ artifacts: Some(contract::ExportArtifacts {
+ models_dir: Some("src/generated".to_string()),
+ constants_dir: Some("src/generated".to_string()),
+ wasm_dist_dir: Some("dist".to_string()),
+ manifest_file: Some("export-manifest.json".to_string()),
+ }),
+ }
+ }
+
+ #[test]
+ fn selected_package_entries_match_crate_and_package_selectors() {
+ let mapping = test_ts_mapping();
+
+ let all = selected_package_entries(&mapping, None).expect("select all");
+ assert_eq!(all.len(), 2);
+
+ let by_crate = selected_package_entries(&mapping, Some("radroots-core")).expect("by crate");
+ assert_eq!(by_crate.len(), 1);
+ assert_eq!(by_crate[0].0.as_str(), "radroots-core");
+
+ let by_short = selected_package_entries(&mapping, Some("core")).expect("by short crate");
+ assert_eq!(by_short.len(), 1);
+ assert_eq!(by_short[0].1.as_str(), "@radroots/core");
+
+ let by_package =
+ selected_package_entries(&mapping, Some("@radroots/core")).expect("by package");
+ assert_eq!(by_package.len(), 1);
+ assert_eq!(by_package[0].0.as_str(), "radroots-core");
+
+ let wasm = selected_package_entries(&mapping, Some("events-codec-wasm")).expect("wasm");
+ assert_eq!(wasm.len(), 1);
+ assert_eq!(wasm[0].0.as_str(), "radroots-events-codec-wasm");
+ }
+
+ #[test]
+ fn selected_package_entries_fail_for_unknown_selector() {
+ let mapping = test_ts_mapping();
+ let err = selected_package_entries(&mapping, Some("unknown")).expect_err("unknown");
+ assert!(err.contains("unknown ts export crate selector"));
+ }
+
#[test]
fn package_dir_and_artifact_helpers_validate_values() {
let base = PathBuf::from("/tmp/base");
diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs
@@ -11,6 +11,7 @@ use std::process::ExitCode;
fn usage() {
eprintln!("usage:");
eprintln!(" cargo xtask sdk export-ts [--out <dir>]");
+ eprintln!(" cargo xtask sdk export-ts-crate --crate <crate> [--out <dir>]");
eprintln!(" cargo xtask sdk export-ts-models [--out <dir>]");
eprintln!(" cargo xtask sdk export-ts-constants [--out <dir>]");
eprintln!(" cargo xtask sdk export-ts-wasm [--out <dir>]");
@@ -45,6 +46,41 @@ fn parse_out_dir(args: &[String], workspace_root: &Path) -> Result<PathBuf, Stri
Err("invalid export args, expected --out <dir>".to_string())
}
+fn parse_crate_out_dir(
+ args: &[String],
+ workspace_root: &Path,
+) -> Result<(String, PathBuf), String> {
+ let mut crate_selector = None;
+ let mut out_dir = workspace_root.join("target").join("sdk-export");
+ let mut index = 0usize;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--crate" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("invalid export args, expected --crate <crate>".to_string());
+ };
+ crate_selector = Some(value.clone());
+ index += 2;
+ }
+ "--out" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("invalid export args, expected --out <dir>".to_string());
+ };
+ out_dir = PathBuf::from(value);
+ index += 2;
+ }
+ _ => {
+ return Err(
+ "invalid export args, expected --crate <crate> [--out <dir>]".to_string(),
+ );
+ }
+ }
+ }
+ let crate_selector =
+ crate_selector.ok_or_else(|| "missing required --crate <crate>".to_string())?;
+ Ok((crate_selector, out_dir))
+}
+
fn export_ts_models(args: &[String]) -> Result<(), String> {
let root = workspace_root()?;
let out_dir = parse_out_dir(args, &root)?;
@@ -86,6 +122,19 @@ fn export_ts(args: &[String]) -> Result<(), String> {
Ok(())
}
+fn export_ts_crate(args: &[String]) -> Result<(), String> {
+ let root = workspace_root()?;
+ let (crate_selector, out_dir) = parse_crate_out_dir(args, &root)?;
+ let manifest = export_ts::export_ts_bundle_for_crate(&root, &out_dir, &crate_selector)?;
+ eprintln!(
+ "exported ts sdk crate {} to {}",
+ crate_selector,
+ out_dir.display()
+ );
+ eprintln!("wrote export manifest {}", manifest.display());
+ Ok(())
+}
+
fn validate_contract() -> Result<(), String> {
let root = workspace_root()?;
let bundle = contract::load_contract_bundle(&root)?;
@@ -101,6 +150,7 @@ fn validate_contract() -> Result<(), String> {
fn run_sdk(args: &[String]) -> Result<(), String> {
match args.first().map(String::as_str) {
Some("export-ts") => export_ts(&args[1..]),
+ Some("export-ts-crate") => export_ts_crate(&args[1..]),
Some("export-ts-models") => export_ts_models(&args[1..]),
Some("export-ts-constants") => export_ts_constants(&args[1..]),
Some("export-ts-wasm") => export_ts_wasm(&args[1..]),