commit f54dd5d07226bfd8d704690df0d489103a036134
parent 7b42fbc7cf197aa5be4dc51a5ad194bcfdbec5eb
Author: triesap <tyson@radroots.org>
Date: Wed, 24 Jun 2026 06:13:47 +0000
feat: add dto registry TypeScript renderer
Diffstat:
6 files changed, 724 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -500,6 +500,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
+name = "dto_bindgen_core"
+version = "0.1.0"
+source = "git+https://github.com/triesap/dto_bindgen?rev=96ed6c691aacab31860828d25da2e0167b13d92c#96ed6c691aacab31860828d25da2e0167b13d92c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "syn",
+ "toml",
+]
+
+[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2031,6 +2045,7 @@ dependencies = [
name = "radroots_sdk_xtask"
version = "0.1.0"
dependencies = [
+ "dto_bindgen_core",
"radroots_core_bindings",
"radroots_events_bindings",
"radroots_events_indexed_bindings",
diff --git a/Cargo.toml b/Cargo.toml
@@ -27,6 +27,8 @@ homepage = "https://radroots.org"
readme = "README"
[workspace.dependencies]
+dto_bindgen = { git = "https://github.com/triesap/dto_bindgen", rev = "96ed6c691aacab31860828d25da2e0167b13d92c" }
+dto_bindgen_core = { git = "https://github.com/triesap/dto_bindgen", rev = "96ed6c691aacab31860828d25da2e0167b13d92c", package = "dto_bindgen_core" }
radroots_core = { path = "../lib/crates/core", version = "0.1.0-alpha.2", default-features = false }
radroots_authority = { path = "../lib/crates/authority", version = "0.1.0-alpha.2", default-features = false }
radroots_event_store = { path = "../lib/crates/event_store", version = "0.1.0-alpha.2", default-features = false }
diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml
@@ -13,6 +13,7 @@ name = "radroots_sdk_xtask"
path = "src/main.rs"
[dependencies]
+dto_bindgen_core = { workspace = true }
radroots_sdk_binding_model = { path = "../../crates/binding_model" }
radroots_core_bindings = { path = "../../crates/core_bindings" }
radroots_events_bindings = { path = "../../crates/events_bindings" }
diff --git a/tools/xtask/src/dto_render.rs b/tools/xtask/src/dto_render.rs
@@ -0,0 +1,646 @@
+use std::collections::{BTreeMap, BTreeSet};
+
+use dto_bindgen_core::{
+ BackendId, Config, EnumDef, EnumRepr, FieldDef, IntRepr, LargeIntPolicy, Primitive, Registry,
+ StructDef, TypeDef, TypeId, TypeRef, VariantDef, VariantShape,
+};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DtoTypesModule {
+ imports_ts: String,
+ body_ts: String,
+}
+
+impl DtoTypesModule {
+ pub fn new(imports_ts: impl Into<String>, body_ts: impl Into<String>) -> Self {
+ Self {
+ imports_ts: imports_ts.into(),
+ body_ts: body_ts.into(),
+ }
+ }
+
+ pub fn imports_ts(&self) -> Option<&str> {
+ (!self.imports_ts.is_empty()).then_some(self.imports_ts.as_str())
+ }
+
+ pub fn body_ts(&self) -> &str {
+ self.body_ts.as_str()
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DtoTypeImport {
+ import_name: String,
+ from: String,
+}
+
+impl DtoTypeImport {
+ pub fn new(import_name: impl Into<String>, from: impl Into<String>) -> Self {
+ Self {
+ import_name: import_name.into(),
+ from: from.into(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DtoRegistryRenderOptions {
+ config: Config,
+ external_imports: BTreeMap<TypeId, DtoTypeImport>,
+}
+
+impl DtoRegistryRenderOptions {
+ pub fn new(config: Config) -> Self {
+ Self {
+ config,
+ external_imports: BTreeMap::new(),
+ }
+ }
+
+ pub fn with_external_type(
+ mut self,
+ type_id: TypeId,
+ import_name: impl Into<String>,
+ from: impl Into<String>,
+ ) -> Self {
+ self.external_imports
+ .insert(type_id, DtoTypeImport::new(import_name, from));
+ self
+ }
+}
+
+impl Default for DtoRegistryRenderOptions {
+ fn default() -> Self {
+ Self::new(Config::default())
+ }
+}
+
+pub fn render_registry_types(
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+) -> Result<DtoTypesModule, String> {
+ let mut imports = BTreeMap::<String, BTreeSet<String>>::new();
+ let mut declarations = Vec::new();
+
+ for (type_id, type_def) in ®istry.types_by_id {
+ if options.external_imports.contains_key(type_id) {
+ continue;
+ }
+ declarations.push(render_type_def(
+ *type_id,
+ type_def,
+ registry,
+ options,
+ &mut imports,
+ )?);
+ }
+
+ Ok(DtoTypesModule::new(
+ render_imports(&imports),
+ declarations.join("\n\n"),
+ ))
+}
+
+fn render_type_def(
+ type_id: TypeId,
+ type_def: &TypeDef,
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<String, String> {
+ match type_def {
+ TypeDef::Struct(def) => render_struct(type_id, def, registry, options, imports),
+ TypeDef::Enum(def) => render_enum(type_id, def, registry, options, imports),
+ }
+}
+
+fn render_struct(
+ _type_id: TypeId,
+ def: &StructDef,
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<String, String> {
+ Ok(format!(
+ "export type {}{} = {};",
+ struct_type_name(def),
+ render_generic_params(def.generics.iter().map(|param| param.name.as_str())),
+ render_object_fields(&def.fields, registry, options, imports)?
+ ))
+}
+
+fn render_enum(
+ _type_id: TypeId,
+ def: &EnumDef,
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<String, String> {
+ match &def.repr {
+ EnumRepr::External
+ if def
+ .variants
+ .iter()
+ .all(|variant| matches!(variant.shape, VariantShape::Unit)) =>
+ {
+ let variants = def
+ .variants
+ .iter()
+ .map(|variant| quote_string(&variant.wire_name))
+ .collect::<Vec<_>>();
+ Ok(format!(
+ "export type {} = {};",
+ enum_type_name(def),
+ render_union(variants)
+ ))
+ }
+ EnumRepr::Internal { tag } => {
+ render_tagged_enum(def, tag, None, registry, options, imports)
+ }
+ EnumRepr::Adjacent { tag, content } => {
+ render_tagged_enum(def, tag, Some(content.as_str()), registry, options, imports)
+ }
+ EnumRepr::External | EnumRepr::Untagged => Err(format!(
+ "unsupported enum representation for {}",
+ enum_type_name(def)
+ )),
+ }
+}
+
+fn render_tagged_enum(
+ def: &EnumDef,
+ tag: &str,
+ content: Option<&str>,
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<String, String> {
+ let variants = def
+ .variants
+ .iter()
+ .map(|variant| {
+ render_tagged_variant(def, variant, tag, content, registry, options, imports)
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(format!(
+ "export type {} = {};",
+ enum_type_name(def),
+ render_union(variants)
+ ))
+}
+
+fn render_tagged_variant(
+ def: &EnumDef,
+ variant: &VariantDef,
+ tag: &str,
+ content: Option<&str>,
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<String, String> {
+ let mut fields = vec![format!(
+ "{}: {}",
+ render_property_name(tag),
+ quote_string(&variant.wire_name)
+ )];
+
+ match (&variant.shape, content) {
+ (VariantShape::Unit, _) => {}
+ (VariantShape::Struct(variant_fields), Some(content)) => {
+ fields.push(format!(
+ "{}: {}",
+ render_property_name(content),
+ render_object_fields(variant_fields, registry, options, imports)?
+ ));
+ }
+ (VariantShape::Struct(variant_fields), None) => {
+ fields.extend(render_object_field_list(
+ variant_fields,
+ registry,
+ options,
+ imports,
+ )?);
+ }
+ (VariantShape::Newtype(ty), Some(content)) => {
+ fields.push(format!(
+ "{}: {}",
+ render_property_name(content),
+ render_type_ref(ty, None, registry, options, imports)?
+ ));
+ }
+ (VariantShape::Tuple(items), Some(content)) => {
+ let rendered = items
+ .iter()
+ .map(|item| render_type_ref(item, None, registry, options, imports))
+ .collect::<Result<Vec<_>, _>>()?;
+ fields.push(format!(
+ "{}: [{}]",
+ render_property_name(content),
+ rendered.join(", ")
+ ));
+ }
+ (VariantShape::Newtype(_) | VariantShape::Tuple(_), None) => {
+ return Err(format!(
+ "unsupported internally tagged variant shape for {}.{}",
+ enum_type_name(def),
+ variant.rust_name
+ ));
+ }
+ }
+
+ Ok(format!("{{ {}, }}", fields.join(", ")))
+}
+
+fn render_object_fields(
+ fields: &[FieldDef],
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<String, String> {
+ let rendered = render_object_field_list(fields, registry, options, imports)?;
+ if rendered.is_empty() {
+ return Ok("{}".to_owned());
+ }
+ Ok(format!("{{ {}, }}", rendered.join(", ")))
+}
+
+fn render_object_field_list(
+ fields: &[FieldDef],
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<Vec<String>, String> {
+ fields
+ .iter()
+ .filter(|field| field.presence.is_serialized())
+ .map(|field| render_object_field(field, registry, options, imports))
+ .collect()
+}
+
+fn render_object_field(
+ field: &FieldDef,
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<String, String> {
+ let optional = if field.presence.required_on_deserialize {
+ ""
+ } else {
+ "?"
+ };
+ let mut value = render_type_ref(&field.ty, field.int_repr, registry, options, imports)?;
+ if field.presence.nullable {
+ value = render_nullable(value);
+ }
+ Ok(format!(
+ "{}{}: {}",
+ render_property_name(&field.target.typescript),
+ optional,
+ value
+ ))
+}
+
+fn render_type_ref(
+ ty: &TypeRef,
+ int_repr: Option<IntRepr>,
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<String, String> {
+ match ty {
+ TypeRef::Primitive(primitive) => render_primitive(*primitive, int_repr, options),
+ TypeRef::String => Ok("string".to_owned()),
+ TypeRef::Bytes(_) => Ok("Uint8Array".to_owned()),
+ TypeRef::Option(inner) => Ok(render_nullable(render_type_ref(
+ inner, int_repr, registry, options, imports,
+ )?)),
+ TypeRef::Vec(inner) => Ok(format!(
+ "Array<{}>",
+ render_type_ref(inner, int_repr, registry, options, imports)?
+ )),
+ TypeRef::Array { item, len } => {
+ let item = render_type_ref(item, int_repr, registry, options, imports)?;
+ Ok(format!("[{}]", vec![item; *len].join(", ")))
+ }
+ TypeRef::Map { key, value } => {
+ if !matches!(key.as_ref(), TypeRef::String) {
+ return Err("non-string map keys are unsupported".to_owned());
+ }
+ Ok(format!(
+ "Record<string, {}>",
+ render_type_ref(value, int_repr, registry, options, imports)?
+ ))
+ }
+ TypeRef::Named(type_id) => render_named_type(*type_id, registry, options, imports),
+ TypeRef::GenericParam(name) => Ok(name.clone()),
+ TypeRef::Override(target) if target.backend == BackendId::TypeScript => {
+ Ok(target.target_type.clone())
+ }
+ TypeRef::Override(_) => Err("target override is for a different backend".to_owned()),
+ }
+}
+
+fn render_named_type(
+ type_id: TypeId,
+ registry: &Registry,
+ options: &DtoRegistryRenderOptions,
+ imports: &mut BTreeMap<String, BTreeSet<String>>,
+) -> Result<String, String> {
+ if let Some(import) = options.external_imports.get(&type_id) {
+ imports
+ .entry(import.from.clone())
+ .or_default()
+ .insert(import.import_name.clone());
+ return Ok(import.import_name.clone());
+ }
+
+ let type_def = registry
+ .type_def(type_id)
+ .ok_or_else(|| format!("missing named type reference {type_id}"))?;
+ Ok(type_name(type_def).to_owned())
+}
+
+fn render_primitive(
+ primitive: Primitive,
+ int_repr: Option<IntRepr>,
+ options: &DtoRegistryRenderOptions,
+) -> Result<String, String> {
+ if primitive.requires_explicit_integer_policy() {
+ return match int_repr {
+ Some(IntRepr::JsonString) => Ok("string".to_owned()),
+ Some(IntRepr::JsonNumberUnsafe) => Ok("number".to_owned()),
+ Some(IntRepr::NonJsonBigint) => Ok("bigint".to_owned()),
+ None => match options.config.numeric.large_int_policy {
+ LargeIntPolicy::RequireExplicit => {
+ Err("large integer field requires explicit numeric policy".to_owned())
+ }
+ LargeIntPolicy::JsonString => Ok("string".to_owned()),
+ LargeIntPolicy::JsonNumberUnsafe => Ok("number".to_owned()),
+ LargeIntPolicy::NonJsonBigint => Ok("bigint".to_owned()),
+ },
+ };
+ }
+
+ match primitive {
+ Primitive::Bool => Ok("boolean".to_owned()),
+ primitive if primitive.is_integer() || primitive.is_float() => Ok("number".to_owned()),
+ _ => unreachable!("all primitive variants are covered by bool, integer, or float"),
+ }
+}
+
+fn render_imports(imports: &BTreeMap<String, BTreeSet<String>>) -> String {
+ let mut rendered = String::new();
+ for (from, names) in imports {
+ if names.len() == 1 {
+ rendered.push_str("import type { ");
+ rendered.push_str(names.iter().next().expect("single import name"));
+ rendered.push_str(" } from ");
+ rendered.push_str("e_string(from));
+ rendered.push_str(";\n");
+ } else {
+ rendered.push_str("import type {\n");
+ for name in names {
+ rendered.push_str(" ");
+ rendered.push_str(name);
+ rendered.push_str(",\n");
+ }
+ rendered.push_str("} from ");
+ rendered.push_str("e_string(from));
+ rendered.push_str(";\n");
+ }
+ }
+ if !rendered.is_empty() {
+ rendered.push('\n');
+ }
+ rendered
+}
+
+fn render_nullable(value: String) -> String {
+ if value.split(" | ").any(|part| part == "null") {
+ value
+ } else {
+ format!("{value} | null")
+ }
+}
+
+fn render_union(items: Vec<String>) -> String {
+ if items.is_empty() {
+ return "never".to_owned();
+ }
+
+ let mut seen = BTreeSet::new();
+ let mut rendered = Vec::new();
+ for item in items {
+ if seen.insert(item.clone()) {
+ rendered.push(item);
+ }
+ }
+ rendered.join(" | ")
+}
+
+fn render_generic_params<'a>(params: impl Iterator<Item = &'a str>) -> String {
+ let params = params.collect::<Vec<_>>();
+ if params.is_empty() {
+ String::new()
+ } else {
+ format!("<{}>", params.join(", "))
+ }
+}
+
+fn type_name(type_def: &TypeDef) -> &str {
+ match type_def {
+ TypeDef::Struct(def) => struct_type_name(def),
+ TypeDef::Enum(def) => enum_type_name(def),
+ }
+}
+
+fn struct_type_name(def: &StructDef) -> &str {
+ def.attrs.ts_name.as_deref().unwrap_or(&def.export_name)
+}
+
+fn enum_type_name(def: &EnumDef) -> &str {
+ def.attrs.ts_name.as_deref().unwrap_or(&def.export_name)
+}
+
+fn render_property_name(value: &str) -> String {
+ if is_identifier(value) {
+ value.to_owned()
+ } else {
+ quote_string(value)
+ }
+}
+
+fn is_identifier(value: &str) -> bool {
+ let mut chars = value.chars();
+ match chars.next() {
+ Some(first) if first == '_' || first == '$' || first.is_ascii_alphabetic() => {}
+ _ => return false,
+ }
+ chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric())
+}
+
+fn quote_string(value: &str) -> String {
+ let mut escaped = String::with_capacity(value.len() + 2);
+ escaped.push('"');
+ for ch in value.chars() {
+ match ch {
+ '\\' => escaped.push_str("\\\\"),
+ '"' => escaped.push_str("\\\""),
+ '\n' => escaped.push_str("\\n"),
+ '\r' => escaped.push_str("\\r"),
+ '\t' => escaped.push_str("\\t"),
+ ch => escaped.push(ch),
+ }
+ }
+ escaped.push('"');
+ escaped
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{DtoRegistryRenderOptions, render_registry_types};
+ use dto_bindgen_core::{
+ BackendId, EnumDef, EnumRepr, FieldDef, FieldPresence, GenericParam, IdentName, IntRepr,
+ Primitive, Registry, RustTypeId, SourceSpan, StructDef, TargetFieldNames, TargetOverride,
+ TypeDef, TypeRef, VariantDef, VariantShape, WireFieldNames,
+ };
+
+ fn span() -> SourceSpan {
+ SourceSpan::new("src/dto.rs", 1, 1)
+ }
+
+ fn field(name: &str, target: &str, ty: TypeRef) -> FieldDef {
+ FieldDef::new(
+ IdentName::new(name),
+ WireFieldNames::same(target),
+ TargetFieldNames::new(target, name),
+ ty,
+ span(),
+ )
+ }
+
+ #[test]
+ fn renders_synthetic_registry_as_package_level_types() {
+ let mut registry = Registry::new();
+ let external_id = registry.register_type(
+ RustTypeId::new("external", "ExternalThing"),
+ TypeDef::Struct(StructDef::new("ExternalThing", "ExternalThing", span())),
+ );
+ let envelope = TypeDef::Struct(StructDef {
+ generics: vec![GenericParam::new("T")],
+ ..StructDef::new("Envelope", "Envelope", span()).with_field(field(
+ "value",
+ "value",
+ TypeRef::GenericParam("T".to_owned()),
+ ))
+ });
+ registry.register_type(RustTypeId::new("sdk", "Envelope"), envelope);
+ let thing = TypeDef::Struct(
+ StructDef::new("SyntheticThing", "SyntheticThing", span())
+ .with_field(field("external", "external", TypeRef::Named(external_id)))
+ .with_field(
+ field(
+ "maybe_count",
+ "maybeCount",
+ TypeRef::Primitive(Primitive::I64),
+ )
+ .with_presence(FieldPresence::optional_nullable())
+ .with_int_repr(IntRepr::JsonString),
+ )
+ .with_field(field(
+ "point",
+ "point",
+ TypeRef::array(TypeRef::Primitive(Primitive::F64), 2),
+ ))
+ .with_field(field(
+ "envelope",
+ "envelope",
+ TypeRef::Override(TargetOverride::new(
+ BackendId::TypeScript,
+ "Envelope<ExternalThing>",
+ )),
+ )),
+ );
+ registry.register_type(RustTypeId::new("sdk", "SyntheticThing"), thing);
+ let mode = TypeDef::Enum(
+ EnumDef::new("SyntheticMode", "SyntheticMode", EnumRepr::External, span())
+ .with_variant(VariantDef::new(
+ "Ready",
+ "ready",
+ VariantShape::Unit,
+ span(),
+ ))
+ .with_variant(VariantDef::new("Done", "done", VariantShape::Unit, span())),
+ );
+ registry.register_type(RustTypeId::new("sdk", "SyntheticMode"), mode);
+ let event = TypeDef::Enum(
+ EnumDef::new(
+ "SyntheticEvent",
+ "SyntheticEvent",
+ EnumRepr::Adjacent {
+ tag: "type".to_owned(),
+ content: "payload".to_owned(),
+ },
+ span(),
+ )
+ .with_variant(VariantDef::new(
+ "Created",
+ "created",
+ VariantShape::Struct(vec![field("id", "id", TypeRef::String)]),
+ span(),
+ ))
+ .with_variant(VariantDef::new(
+ "Archived",
+ "archived",
+ VariantShape::Struct(vec![
+ field("reason", "reason", TypeRef::option(TypeRef::String))
+ .with_presence(FieldPresence::optional_nullable_skip_if_none()),
+ ]),
+ span(),
+ )),
+ );
+ registry.register_type(RustTypeId::new("sdk", "SyntheticEvent"), event);
+
+ let rendered = render_registry_types(
+ ®istry,
+ &DtoRegistryRenderOptions::default().with_external_type(
+ external_id,
+ "ExternalThing",
+ "@radroots/external-bindings",
+ ),
+ )
+ .expect("registry renders");
+
+ assert_eq!(
+ rendered.imports_ts(),
+ Some("import type { ExternalThing } from \"@radroots/external-bindings\";\n\n")
+ );
+ assert_eq!(
+ rendered.body_ts(),
+ "export type Envelope<T> = { value: T, };\n\nexport type SyntheticThing = { external: ExternalThing, maybeCount?: string | null, point: [number, number], envelope: Envelope<ExternalThing>, };\n\nexport type SyntheticMode = \"ready\" | \"done\";\n\nexport type SyntheticEvent = { type: \"created\", payload: { id: string, }, } | { type: \"archived\", payload: { reason?: string | null, }, };"
+ );
+ }
+
+ #[test]
+ fn requires_explicit_large_integer_policy() {
+ let mut registry = Registry::new();
+ registry.register_type(
+ RustTypeId::new("sdk", "Counter"),
+ TypeDef::Struct(
+ StructDef::new("Counter", "Counter", span()).with_field(field(
+ "value",
+ "value",
+ TypeRef::Primitive(Primitive::U64),
+ )),
+ ),
+ );
+
+ let error = render_registry_types(®istry, &DtoRegistryRenderOptions::default())
+ .expect_err("missing policy blocks render");
+
+ assert_eq!(
+ error,
+ "large integer field requires explicit numeric policy"
+ );
+ }
+}
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -2,6 +2,8 @@ mod check;
mod contracts;
mod coverage;
mod coverage_policy;
+#[allow(dead_code)]
+mod dto_render;
mod fs;
mod generate;
mod manifest;
diff --git a/tools/xtask/src/output.rs b/tools/xtask/src/output.rs
@@ -1,4 +1,5 @@
use crate::{
+ dto_render::DtoTypesModule,
manifest::manifest_file_name,
manifest::package_manifest,
package_matrix::{PackageSpec, package_specs},
@@ -19,25 +20,36 @@ pub struct GeneratedFile {
pub contents: String,
}
+#[allow(dead_code)]
pub enum TsSource {
+ DtoRegistry(DtoTypesModule),
Module(TsModule),
}
impl TsSource {
fn render(&self) -> String {
match self {
+ Self::DtoRegistry(module) => module.body_ts().to_owned(),
Self::Module(module) => module.render(),
}
}
+
+ fn imports(&self) -> Option<&str> {
+ match self {
+ Self::DtoRegistry(module) => module.imports_ts(),
+ Self::Module(_) => None,
+ }
+ }
}
impl PackageOutput {
pub fn files(&self) -> Vec<GeneratedFile> {
let mut files = Vec::new();
if let Some(types_ts) = &self.types_ts {
+ let imports = combined_imports(self.types_imports_ts, types_ts.imports());
files.push(GeneratedFile {
relative_path: format!("src/generated/{}", generated_types_file()),
- contents: render_ts(types_ts, self.types_imports_ts),
+ contents: render_ts(types_ts, imports.as_deref()),
});
}
if let Some(constants_ts) = &self.constants_ts {
@@ -144,6 +156,15 @@ fn render_ts(source: &TsSource, imports: Option<&str>) -> String {
rendered
}
+fn combined_imports(first: Option<&str>, second: Option<&str>) -> Option<String> {
+ match (first, second) {
+ (Some(first), Some(second)) => Some(format!("{first}{second}")),
+ (Some(first), None) => Some(first.to_owned()),
+ (None, Some(second)) => Some(second.to_owned()),
+ (None, None) => None,
+ }
+}
+
const EVENTS_TYPES_IMPORTS_TS: &str = r#"import type {
RadrootsCoreCurrency,
RadrootsCoreDecimal,
@@ -215,7 +236,8 @@ fn render_index(output: &PackageOutput) -> String {
#[cfg(test)]
mod tests {
- use super::{TsSource, package_outputs, render_ts};
+ use super::{PackageOutput, TsSource, package_outputs, render_ts};
+ use crate::{dto_render::DtoTypesModule, package_matrix::package_specs};
use radroots_sdk_binding_model::{module, string, type_alias};
#[test]
@@ -266,4 +288,38 @@ mod tests {
assert!(package_names.contains(&"@radroots/trade-bindings"));
assert!(package_names.contains(&"@radroots/types-bindings"));
}
+
+ #[test]
+ fn dto_registry_source_uses_package_shell() {
+ let output = PackageOutput {
+ spec: package_specs()[0],
+ types_ts: Some(TsSource::DtoRegistry(DtoTypesModule::new(
+ "import type { ExternalThing } from \"@radroots/external-bindings\";\n\n",
+ "export type SyntheticThing = { external: ExternalThing, };",
+ ))),
+ types_imports_ts: Some("import type { LocalPrelude } from \"@radroots/local\";\n\n"),
+ constants_ts: None,
+ kinds_ts: None,
+ };
+ let files = output.files();
+ let types = files
+ .iter()
+ .find(|file| file.relative_path == "src/generated/types.ts")
+ .expect("types file");
+ let manifest = files
+ .iter()
+ .find(|file| file.relative_path == "src/generated/sdk-manifest.json")
+ .expect("manifest file");
+ let index = files
+ .iter()
+ .find(|file| file.relative_path == "src/index.ts")
+ .expect("index file");
+
+ assert_eq!(
+ types.contents,
+ "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type { LocalPrelude } from \"@radroots/local\";\n\nimport type { ExternalThing } from \"@radroots/external-bindings\";\n\nexport type SyntheticThing = { external: ExternalThing, };\n"
+ );
+ assert!(manifest.contents.contains("\"generated\": true"));
+ assert_eq!(index.contents, "export * from \"./generated/types.js\";\n");
+ }
}