sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

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:
MCargo.lock | 15+++++++++++++++
MCargo.toml | 2++
Mtools/xtask/Cargo.toml | 1+
Atools/xtask/src/dto_render.rs | 646+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 2++
Mtools/xtask/src/output.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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 &registry.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(&quote_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(&quote_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( + &registry, + &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(&registry, &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"); + } }