1use std::collections::BTreeMap;
12
13use syn::{
14 Fields, GenericArgument, ItemEnum, ItemStruct, PathArguments, Type, TypeArray, TypePath as SynTypePath,
15 TypeReference, TypeSlice, TypeTuple,
16};
17
18use crate::attr::{
19 ContainerAttrs, FieldAttrs, VariantAttrs, extract_container_attrs, extract_field_attrs, extract_ontogen_attrs,
20 extract_variant_attrs,
21};
22use crate::order;
23use crate::resolve::ModuleImports;
24use crate::types::{BigIntBehavior, EmitConfig, EmitError, RenameAll, TypePath};
25
26pub fn emit(
55 roots: &[TypePath],
56 type_pool: &BTreeMap<TypePath, syn::Item>,
57 config: &EmitConfig,
58) -> Result<String, Vec<EmitError>> {
59 emit_with_imports(roots, type_pool, &ModuleImports::default(), config)
60}
61
62pub fn emit_with_imports(
67 roots: &[TypePath],
68 type_pool: &BTreeMap<TypePath, syn::Item>,
69 imports: &ModuleImports,
70 config: &EmitConfig,
71) -> Result<String, Vec<EmitError>> {
72 let mut errors: Vec<EmitError> = Vec::new();
73
74 let graph = order::dependency_graph_with_imports(type_pool, imports);
76 let reachable = order::reachable_from(roots, &graph);
77
78 for root in roots {
82 if !type_pool.contains_key(root) {
83 errors.push(EmitError::UnresolvedReference {
84 name: format!("root type `{root}` is not present in the type pool"),
85 referenced_by: root.clone(),
86 });
87 }
88 }
89
90 let mut names: BTreeMap<TypePath, String> = BTreeMap::new();
94 for path in &reachable {
95 let Some(item) = type_pool.get(path) else {
96 continue;
97 };
98 let attrs = item_attrs(item);
99 match extract_ontogen_attrs(attrs, path) {
100 Ok(ontogen) => {
101 let name = ontogen.ts_name.unwrap_or_else(|| path.terminal().to_string());
102 names.insert(path.clone(), name);
103 }
104 Err(err) => {
105 errors.push(err);
106 names.insert(path.clone(), path.terminal().to_string());
107 }
108 }
109 }
110
111 {
113 let mut by_name: BTreeMap<String, Vec<TypePath>> = BTreeMap::new();
114 for (path, name) in &names {
115 by_name.entry(name.clone()).or_default().push(path.clone());
116 }
117 for (name, paths) in by_name {
118 if paths.len() > 1 {
119 errors.push(EmitError::NameCollision { name, paths });
120 }
121 }
122 }
123
124 let ordered = order::topo_order(&graph, &reachable);
126
127 let mut outputs: Vec<String> = Vec::with_capacity(ordered.len());
129 for path in &ordered {
130 let Some(item) = type_pool.get(path) else {
131 continue;
132 };
133
134 let ontogen_attrs = match extract_ontogen_attrs(item_attrs(item), path) {
135 Ok(a) => a,
136 Err(_) => continue, };
138 let resolved_name = names.get(path).cloned().unwrap_or_else(|| path.terminal().to_string());
139
140 if let Some(target) = ontogen_attrs.ts_opaque {
141 outputs.push(format!("export type {resolved_name} = {target};"));
142 continue;
143 }
144
145 match item {
146 syn::Item::Struct(s) => match emit_struct_named(s, config, Some(&resolved_name)) {
147 Ok(ts) => outputs.push(ts),
148 Err(e) => errors.push(e),
149 },
150 syn::Item::Enum(e) => match emit_enum_named(e, config, Some(&resolved_name)) {
151 Ok(ts) => outputs.push(ts),
152 Err(err) => errors.push(err),
153 },
154 syn::Item::Type(t) => {
155 let synthetic_path = TypePath::new(vec![path.terminal().to_string()]).expect("non-empty");
159 match emit_type(&t.ty, config, &synthetic_path) {
160 Ok(inner) => outputs.push(format!("export type {resolved_name} = {inner};")),
161 Err(err) => errors.push(err),
162 }
163 }
164 _ => {
165 }
168 }
169 }
170
171 if !errors.is_empty() {
172 return Err(errors);
173 }
174
175 Ok(outputs.join("\n\n"))
176}
177
178fn item_attrs(item: &syn::Item) -> &[syn::Attribute] {
180 match item {
181 syn::Item::Struct(s) => &s.attrs,
182 syn::Item::Enum(e) => &e.attrs,
183 syn::Item::Type(t) => &t.attrs,
184 _ => &[],
185 }
186}
187
188const STANDALONE: &str = "<standalone type>";
191
192fn standalone_path() -> TypePath {
194 TypePath::new(vec![STANDALONE.to_string()]).expect("non-empty")
195}
196
197pub fn render_type(ty: &Type, config: &EmitConfig) -> Result<String, EmitError> {
211 emit_type(ty, config, &standalone_path())
212}
213
214pub fn render_type_str(rust_ty: &str, config: &EmitConfig) -> Result<String, EmitError> {
225 let parsed: Type = syn::parse_str(rust_ty).map_err(|err| EmitError::UnsupportedShape {
226 type_path: standalone_path(),
227 reason: format!("`{rust_ty}` does not parse as a Rust type expression: {err}"),
228 })?;
229 render_type(&parsed, config)
230}
231
232pub(crate) fn emit_type(ty: &Type, config: &EmitConfig, referenced_by: &TypePath) -> Result<String, EmitError> {
261 if let Some(inner) = peel_smart_pointer(ty) {
263 return emit_type(inner, config, referenced_by);
264 }
265
266 if let Type::Reference(TypeReference { elem, .. }) = ty {
268 if let Type::Slice(TypeSlice { elem: slice_elem, .. }) = elem.as_ref() {
270 let inner = emit_type(slice_elem, config, referenced_by)?;
271 return Ok(format!("{inner}[]"));
272 }
273 return emit_type(elem, config, referenced_by);
274 }
275
276 if let Type::Array(TypeArray { elem, .. }) = ty {
278 let inner = emit_type(elem, config, referenced_by)?;
279 return Ok(format!("{inner}[]"));
280 }
281
282 if let Type::Slice(TypeSlice { elem, .. }) = ty {
285 let inner = emit_type(elem, config, referenced_by)?;
286 return Ok(format!("{inner}[]"));
287 }
288
289 if let Type::Tuple(TypeTuple { elems, .. }) = ty {
295 if elems.is_empty() {
296 return Ok("null".to_string());
297 }
298 return Err(EmitError::UnsupportedShape {
299 type_path: referenced_by.clone(),
300 reason: format!("tuple type `{}` is not supported; use a named struct", quote::quote!(#ty)),
301 });
302 }
303
304 let path = match ty {
306 Type::Path(p) => p,
307 other => {
308 return Err(EmitError::UnsupportedShape {
309 type_path: referenced_by.clone(),
310 reason: format!("type expression `{}` is not supported in phase 1", quote::quote!(#other)),
311 });
312 }
313 };
314
315 if let Some(name) = terminal_ident(path)
318 && matches!(name.as_str(), "RefCell" | "Mutex" | "RwLock")
319 {
320 return Err(EmitError::UnsupportedShape {
321 type_path: referenced_by.clone(),
322 reason: format!(
323 "{name}<T> is a runtime-coordination primitive and shouldn't appear in wire types; refactor or \
324 use #[ontogen::ts_opaque]"
325 ),
326 });
327 }
328
329 if let Some(container) = match_container(path) {
331 return emit_container(container, config, referenced_by);
332 }
333
334 if let Some(name) = single_segment_ident(path)
336 && let Some(rendered) = primitive_ts(&name, config)
337 {
338 return Ok(rendered.to_string());
339 }
340
341 let segments: Vec<String> = path.path.segments.iter().map(|s| s.ident.to_string()).collect();
349 if segments.is_empty() {
350 return Err(EmitError::UnsupportedShape {
351 type_path: referenced_by.clone(),
352 reason: "type path had no segments".to_string(),
353 });
354 }
355
356 let mut canonical_segs = segments.clone();
359 if canonical_segs.first().map(String::as_str) == Some("crate") {
360 canonical_segs.remove(0);
361 }
362 if let Ok(canonical) = TypePath::new(canonical_segs)
363 && let Some(rendering) = crate::external::resolve(&canonical, &config.external_types)
364 {
365 return Ok(rendering);
366 }
367
368 Ok(segments.last().expect("non-empty after the early return above").clone())
373}
374
375#[allow(dead_code)] pub(crate) fn emit_struct(item: &ItemStruct, config: &EmitConfig) -> Result<String, EmitError> {
422 emit_struct_named(item, config, None)
423}
424
425pub(crate) fn emit_struct_named(
428 item: &ItemStruct,
429 config: &EmitConfig,
430 name_override: Option<&str>,
431) -> Result<String, EmitError> {
432 let raw_name = item.ident.to_string();
433 let name = name_override.map(str::to_string).unwrap_or_else(|| raw_name.clone());
434 let referenced_by = TypePath::new(vec![raw_name]).expect("single segment is non-empty");
435
436 let container = extract_container_attrs(&item.attrs, &referenced_by)?;
437 let effective_rename_all = container.rename_all.or(config.case_default);
438
439 match &item.fields {
440 Fields::Named(fields) => {
441 let collected =
442 collect_named_fields(fields, config, &referenced_by, effective_rename_all, container.default)?;
443 let object = (!collected.properties.is_empty()).then(|| {
448 let body = collected
449 .properties
450 .iter()
451 .map(|(key, opt, ty_ts)| format!(" {key}{opt}: {ty_ts};"))
452 .collect::<Vec<_>>()
453 .join("\n");
454 format!("{{\n{body}\n}}")
455 });
456 Ok(format!("export type {name} = {};", intersect(&collected.intersections, object)))
457 }
458 Fields::Unnamed(_) => Err(EmitError::UnsupportedShape {
459 type_path: referenced_by,
460 reason: "tuple structs are not supported in phase 1; wrap in a named-field struct or use \
461 #[ontogen::ts_opaque]"
462 .to_string(),
463 }),
464 Fields::Unit => Err(EmitError::UnsupportedShape {
465 type_path: referenced_by,
466 reason: "unit structs are not supported in phase 1; use a named-field struct or #[ontogen::ts_opaque]"
467 .to_string(),
468 }),
469 }
470}
471
472struct NamedFields {
475 intersections: Vec<String>,
479 properties: Vec<(String, &'static str, String)>,
481}
482
483fn collect_named_fields(
492 fields: &syn::FieldsNamed,
493 config: &EmitConfig,
494 referenced_by: &TypePath,
495 rename_all: Option<RenameAll>,
496 container_default: bool,
497) -> Result<NamedFields, EmitError> {
498 let mut out = NamedFields { intersections: Vec::new(), properties: Vec::with_capacity(fields.named.len()) };
499 for field in &fields.named {
500 let field_attrs = extract_field_attrs(&field.attrs, referenced_by)?;
501 if field_attrs.skip {
502 continue;
503 }
504 let defaulted = field_attrs.default || container_default;
506 if field_attrs.flatten {
507 out.intersections.push(flatten_member(&field.ty, defaulted, config, referenced_by)?);
510 continue;
511 }
512 let raw_ident = field.ident.as_ref().expect("Fields::Named guarantees a field ident").to_string();
513 let wire_name = field_wire_name(&raw_ident, &field_attrs, rename_all);
514 let key = format_ts_key(&wire_name);
515 let ty_ts = emit_type(&field.ty, config, referenced_by)?;
516 let opt = if defaulted { "?" } else { "" };
521 out.properties.push((key, opt, ty_ts));
522 }
523 Ok(out)
524}
525
526fn intersect(members: &[String], object: Option<String>) -> String {
534 match (members.is_empty(), object) {
535 (true, Some(object)) => object,
536 (true, None) => "{}".to_string(),
537 (false, Some(object)) => format!("{} & {object}", members.join(" & ")),
538 (false, None) => members.join(" & "),
539 }
540}
541
542fn flatten_member(
571 ty: &Type,
572 defaulted: bool,
573 config: &EmitConfig,
574 referenced_by: &TypePath,
575) -> Result<String, EmitError> {
576 if defaulted {
577 return Err(EmitError::UnsupportedShape {
578 type_path: referenced_by.clone(),
579 reason: "a defaulted #[serde(flatten)] field (whether from `#[serde(flatten, default)]` or a container \
580 `#[serde(default)]`) makes the whole flattened group absent-or-present as a unit, which a TS \
581 intersection can't express; drop the `default` or use #[ontogen::ts_opaque(target = \"...\")]"
582 .to_string(),
583 });
584 }
585
586 let mut inner = ty;
587 while let Some(peeled) = peel_smart_pointer(inner) {
588 inner = peeled;
589 }
590
591 if let Type::Path(path) = inner
592 && matches!(match_container(path), Some(Container::Option(_)))
593 {
594 return Err(EmitError::UnsupportedShape {
595 type_path: referenced_by.clone(),
596 reason: "#[serde(flatten)] on an Option<T> makes the whole flattened group absent-or-present as a unit, \
597 which a TS intersection can't express; flatten a non-Option field or use \
598 #[ontogen::ts_opaque(target = \"...\")]"
599 .to_string(),
600 });
601 }
602
603 let rendered = emit_type(inner, config, referenced_by)?;
604 if !is_object_shaped(&rendered) {
605 return Err(EmitError::UnsupportedShape {
606 type_path: referenced_by.clone(),
607 reason: format!(
608 "#[serde(flatten)] needs a field type that renders to a TS object, but this one renders as \
609 `{rendered}`; intersecting that would void or silently drop the parent type. Flatten a struct or a \
610 map, or use #[ontogen::ts_opaque(target = \"...\")]"
611 ),
612 });
613 }
614 Ok(rendered)
615}
616
617const NON_OBJECT_TS_KEYWORDS: &[&str] = &[
621 "any",
622 "bigint",
623 "boolean",
624 "never",
625 "null",
626 "number",
627 "object",
628 "string",
629 "symbol",
630 "undefined",
631 "unknown",
632 "void",
633];
634
635fn is_object_shaped(rendered: &str) -> bool {
641 if rendered.starts_with("Record<") {
642 return true;
643 }
644 is_valid_ts_ident(rendered) && !NON_OBJECT_TS_KEYWORDS.contains(&rendered)
645}
646
647fn field_wire_name(raw_ident: &str, attrs: &FieldAttrs, rename_all: Option<RenameAll>) -> String {
650 if let Some(explicit) = &attrs.rename {
651 return explicit.clone();
652 }
653 if let Some(mode) = rename_all {
654 return mode.apply_to_field(raw_ident);
655 }
656 raw_ident.to_string()
657}
658
659fn variant_field_rename_all(container: &ContainerAttrs, variant: &VariantAttrs) -> Option<RenameAll> {
681 variant.rename_all.or(container.rename_all_fields)
682}
683
684fn variant_wire_name(raw_ident: &str, attrs: &VariantAttrs, rename_all: Option<RenameAll>) -> String {
687 if let Some(explicit) = &attrs.rename {
688 return explicit.clone();
689 }
690 if let Some(mode) = rename_all {
691 return mode.apply_to_variant(raw_ident);
692 }
693 raw_ident.to_string()
694}
695
696fn format_ts_key(name: &str) -> String {
699 if is_valid_ts_ident(name) {
700 name.to_string()
701 } else {
702 let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
706 format!("\"{escaped}\"")
707 }
708}
709
710fn quote(config: &EmitConfig, s: &str) -> String {
724 let d = config.quote_style.delimiter();
725 format!("{d}{s}{d}")
726}
727
728fn is_valid_ts_ident(s: &str) -> bool {
733 let mut chars = s.chars();
734 let Some(first) = chars.next() else {
735 return false;
736 };
737 if !(first.is_ascii_alphabetic() || first == '_' || first == '$') {
738 return false;
739 }
740 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$')
741}
742
743#[allow(dead_code)] pub(crate) fn emit_enum(item: &ItemEnum, config: &EmitConfig) -> Result<String, EmitError> {
777 emit_enum_named(item, config, None)
778}
779
780pub(crate) fn emit_enum_named(
782 item: &ItemEnum,
783 config: &EmitConfig,
784 name_override: Option<&str>,
785) -> Result<String, EmitError> {
786 let raw_name = item.ident.to_string();
787 let name = name_override.map(str::to_string).unwrap_or_else(|| raw_name.clone());
788 let referenced_by = TypePath::new(vec![raw_name]).expect("single segment is non-empty");
789
790 let container = extract_container_attrs(&item.attrs, &referenced_by)?;
791 let effective_rename_all = container.rename_all.or(config.case_default);
792
793 if item.variants.is_empty() {
794 return Ok(format!("export type {name} = never;"));
795 }
796
797 let mut variant_lines: Vec<String> = Vec::with_capacity(item.variants.len());
798 for variant in &item.variants {
799 let variant_attrs = extract_variant_attrs(&variant.attrs, &referenced_by)?;
800 if variant_attrs.skip {
801 continue;
802 }
803 let raw_ident = variant.ident.to_string();
804 let wire_name = variant_wire_name(&raw_ident, &variant_attrs, effective_rename_all);
805 match &variant.fields {
806 Fields::Unit => {
807 variant_lines.push(quote(config, &wire_name));
814 }
815 Fields::Unnamed(fields) => {
816 let key = format_ts_key(&wire_name);
823 match fields.unnamed.len() {
824 0 => variant_lines.push(quote(config, &wire_name)),
825 1 => {
826 let payload_ts = emit_type(&fields.unnamed[0].ty, config, &referenced_by)?;
827 variant_lines.push(format!("{{ {key}: {payload_ts} }}"));
828 }
829 _ => {
830 return Err(EmitError::UnsupportedShape {
831 type_path: referenced_by,
832 reason: format!(
833 "enum variant `{raw_ident}` has {} tuple fields; phase-1 supports unit, single-tuple, \
834 or struct variants (refactor into a struct variant for multi-field payloads)",
835 fields.unnamed.len()
836 ),
837 });
838 }
839 }
840 }
841 Fields::Named(fields) => {
842 let key = format_ts_key(&wire_name);
849 let field_rename_all = variant_field_rename_all(&container, &variant_attrs);
850 let collected = collect_named_fields(fields, config, &referenced_by, field_rename_all, false)?;
854 let object = (!collected.properties.is_empty()).then(|| {
855 let body = collected
856 .properties
857 .iter()
858 .map(|(field_key, opt, ty_ts)| format!("{field_key}{opt}: {ty_ts}"))
859 .collect::<Vec<_>>()
860 .join("; ");
861 format!("{{ {body} }}")
862 });
863 let payload = intersect(&collected.intersections, object);
864 variant_lines.push(format!("{{ {key}: {payload} }}"));
865 }
866 }
867 }
868
869 if variant_lines.is_empty() {
871 return Ok(format!("export type {name} = never;"));
872 }
873
874 let body = variant_lines.join(" | ");
875 Ok(format!("export type {name} = {body};"))
876}
877
878const SMART_POINTERS: &[&str] = &["Box", "Rc", "Arc", "Cow", "Pin"];
880
881fn peel_smart_pointer(ty: &Type) -> Option<&Type> {
884 let Type::Path(path) = ty else {
885 return None;
886 };
887 let segment = path.path.segments.last()?;
888 let name = segment.ident.to_string();
889 if !SMART_POINTERS.contains(&name.as_str()) {
890 return None;
891 }
892 let PathArguments::AngleBracketed(args) = &segment.arguments else {
893 return None;
894 };
895 args.args.iter().find_map(|arg| match arg {
899 GenericArgument::Type(inner) => Some(inner),
900 _ => None,
901 })
902}
903
904fn terminal_ident(path: &SynTypePath) -> Option<String> {
907 if path.qself.is_some() {
908 return None;
909 }
910 path.path.segments.last().map(|s| s.ident.to_string())
911}
912
913fn single_segment_ident(path: &SynTypePath) -> Option<String> {
916 if path.qself.is_some() {
917 return None;
918 }
919 if path.path.segments.len() != 1 {
920 return None;
921 }
922 let segment = &path.path.segments[0];
923 if !matches!(segment.arguments, PathArguments::None) {
924 return None;
925 }
926 Some(segment.ident.to_string())
927}
928
929enum Container<'a> {
931 Option(&'a Type),
933 Vec(&'a Type),
935 Map(&'a Type, &'a Type),
937 Set(&'a Type),
939}
940
941fn match_container(path: &SynTypePath) -> Option<Container<'_>> {
944 if path.qself.is_some() {
945 return None;
946 }
947 let segment = path.path.segments.last()?;
948 let name = segment.ident.to_string();
949 let PathArguments::AngleBracketed(args) = &segment.arguments else {
950 return None;
951 };
952
953 let type_args: Vec<&Type> = args
954 .args
955 .iter()
956 .filter_map(|arg| match arg {
957 GenericArgument::Type(t) => Some(t),
958 _ => None,
959 })
960 .collect();
961
962 match (name.as_str(), type_args.as_slice()) {
963 ("Option", [inner]) => Some(Container::Option(inner)),
964 ("Vec" | "VecDeque", [inner]) => Some(Container::Vec(inner)),
968 ("HashMap" | "BTreeMap", [k, v]) => Some(Container::Map(k, v)),
969 ("HashSet" | "BTreeSet", [inner]) => Some(Container::Set(inner)),
970 _ => None,
971 }
972}
973
974fn emit_container(
976 container: Container<'_>,
977 config: &EmitConfig,
978 referenced_by: &TypePath,
979) -> Result<String, EmitError> {
980 match container {
981 Container::Option(inner) => {
982 let rendered = emit_type(inner, config, referenced_by)?;
983 if rendered.contains(" | ") { Ok(format!("({rendered}) | null")) } else { Ok(format!("{rendered} | null")) }
988 }
989 Container::Vec(inner) | Container::Set(inner) => {
990 let rendered = emit_type(inner, config, referenced_by)?;
991 if rendered.contains(" | ") { Ok(format!("({rendered})[]")) } else { Ok(format!("{rendered}[]")) }
994 }
995 Container::Map(key, value) => {
996 let key_ts = emit_type(key, config, referenced_by)?;
1000 if !is_record_key_renderable(&key_ts) {
1001 return Err(EmitError::UnsupportedShape {
1002 type_path: referenced_by.clone(),
1003 reason: format!(
1004 "map key must render to `string` or a number-like primitive for TS `Record<K, V>`; got \
1005 `{key_ts}`"
1006 ),
1007 });
1008 }
1009 let value_ts = emit_type(value, config, referenced_by)?;
1010 Ok(format!("Record<{key_ts}, {value_ts}>"))
1011 }
1012 }
1013}
1014
1015fn is_record_key_renderable(rendered: &str) -> bool {
1017 matches!(rendered, "string" | "number" | "bigint")
1020}
1021
1022fn primitive_ts(name: &str, config: &EmitConfig) -> Option<&'static str> {
1025 match name {
1026 "bool" => Some("boolean"),
1027 "u64" | "i64" | "u128" | "i128" | "usize" | "isize" => Some(bigint_rendering(config.bigint_behavior)),
1030 "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => Some("number"),
1032 "char" => Some("string"),
1034 "String" | "str" | "PathBuf" | "Path" | "OsString" | "OsStr" | "CString" | "CStr" => Some("string"),
1047 _ => None,
1048 }
1049}
1050
1051fn bigint_rendering(behavior: BigIntBehavior) -> &'static str {
1053 match behavior {
1054 BigIntBehavior::Number => "number",
1055 BigIntBehavior::BigInt => "bigint",
1056 BigIntBehavior::String => "string",
1057 }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063 use crate::types::QuoteStyle;
1064
1065 fn tp(name: &str) -> TypePath {
1067 TypePath::new(vec![name.to_string()]).expect("non-empty")
1068 }
1069
1070 fn ty(src: &str) -> Type {
1071 syn::parse_str(src).unwrap_or_else(|err| panic!("failed to parse `{src}`: {err}"))
1072 }
1073
1074 fn emit(src: &str) -> String {
1075 let config = EmitConfig::default();
1076 emit_type(&ty(src), &config, &tp("Test")).unwrap_or_else(|err| panic!("emit_type(`{src}`) errored: {err}"))
1077 }
1078
1079 fn emit_err(src: &str) -> EmitError {
1080 let config = EmitConfig::default();
1081 emit_type(&ty(src), &config, &tp("Test")).expect_err("expected an EmitError")
1082 }
1083
1084 #[test]
1087 fn primitive_bool() {
1088 assert_eq!(emit("bool"), "boolean");
1089 }
1090
1091 #[test]
1092 fn primitive_small_integers_render_as_number() {
1093 for src in ["u8", "u16", "u32", "i8", "i16", "i32"] {
1094 assert_eq!(emit(src), "number", "{src} should render as number");
1095 }
1096 }
1097
1098 #[test]
1099 fn primitive_floats_render_as_number() {
1100 assert_eq!(emit("f32"), "number");
1101 assert_eq!(emit("f64"), "number");
1102 }
1103
1104 #[test]
1105 fn primitive_big_integers_default_to_number() {
1106 for src in ["u64", "i64", "u128", "i128", "usize", "isize"] {
1107 assert_eq!(emit(src), "number", "{src} should default to number");
1108 }
1109 }
1110
1111 #[test]
1112 fn primitive_big_integers_honor_bigint_behavior() {
1113 let config = EmitConfig { bigint_behavior: BigIntBehavior::BigInt, ..Default::default() };
1114 let rendered = emit_type(&ty("u64"), &config, &tp("Test")).unwrap();
1115 assert_eq!(rendered, "bigint");
1116
1117 let config = EmitConfig { bigint_behavior: BigIntBehavior::String, ..Default::default() };
1118 let rendered = emit_type(&ty("i64"), &config, &tp("Test")).unwrap();
1119 assert_eq!(rendered, "string");
1120 }
1121
1122 #[test]
1123 fn primitive_string_owned_and_borrowed() {
1124 assert_eq!(emit("String"), "string");
1125 assert_eq!(emit("&str"), "string");
1127 }
1128
1129 #[test]
1130 fn primitive_char_renders_as_string() {
1131 assert_eq!(emit("char"), "string");
1132 }
1133
1134 #[test]
1135 fn primitive_std_string_like_types_render_as_string() {
1136 assert_eq!(emit("PathBuf"), "string");
1138 assert_eq!(emit("OsString"), "string");
1139 assert_eq!(emit("CString"), "string");
1140 assert_eq!(emit("Path"), "string");
1142 assert_eq!(emit("OsStr"), "string");
1143 assert_eq!(emit("CStr"), "string");
1144 assert_eq!(emit("&Path"), "string");
1146 assert_eq!(emit("Option<PathBuf>"), "string | null");
1147 assert_eq!(emit("Vec<PathBuf>"), "string[]");
1148 }
1149
1150 #[test]
1151 fn std_string_like_full_path_resolves_through_external_table() {
1152 assert_eq!(emit("std::path::PathBuf"), "string");
1156 assert_eq!(emit("std::path::Path"), "string");
1157 assert_eq!(emit("std::ffi::OsString"), "string");
1158 assert_eq!(emit("std::ffi::OsStr"), "string");
1159 assert_eq!(emit("std::ffi::CString"), "string");
1160 assert_eq!(emit("std::ffi::CStr"), "string");
1161 }
1162
1163 #[test]
1166 fn container_option_renders_union_with_null() {
1167 assert_eq!(emit("Option<u32>"), "number | null");
1168 assert_eq!(emit("Option<String>"), "string | null");
1169 }
1170
1171 #[test]
1172 fn container_vec_renders_as_array() {
1173 assert_eq!(emit("Vec<u32>"), "number[]");
1174 assert_eq!(emit("Vec<String>"), "string[]");
1175 }
1176
1177 #[test]
1178 fn container_set_renders_as_array() {
1179 assert_eq!(emit("HashSet<u32>"), "number[]");
1180 assert_eq!(emit("BTreeSet<String>"), "string[]");
1181 }
1182
1183 #[test]
1184 fn container_vecdeque_renders_as_array() {
1185 assert_eq!(emit("VecDeque<u32>"), "number[]");
1189 assert_eq!(emit("VecDeque<Option<String>>"), "(string | null)[]");
1190 }
1191
1192 #[test]
1193 fn unit_type_renders_as_null() {
1194 assert_eq!(emit("()"), "null");
1196 assert_eq!(emit("Option<()>"), "null | null");
1198 }
1199
1200 #[test]
1201 fn non_empty_tuple_is_rejected() {
1202 match emit_err("(String, u32)") {
1205 EmitError::UnsupportedShape { reason, .. } => {
1206 assert!(reason.contains("tuple"), "reason was: {reason}");
1207 }
1208 other => panic!("expected UnsupportedShape, got {other:?}"),
1209 }
1210 }
1211
1212 #[test]
1213 fn container_hashmap_renders_as_record() {
1214 assert_eq!(emit("HashMap<String, u32>"), "Record<string, number>");
1215 assert_eq!(emit("BTreeMap<String, bool>"), "Record<string, boolean>");
1216 }
1217
1218 #[test]
1219 fn container_hashmap_accepts_numeric_keys() {
1220 assert_eq!(emit("HashMap<u32, String>"), "Record<number, string>");
1221 }
1222
1223 #[test]
1224 fn container_hashmap_rejects_unsupported_keys() {
1225 match emit_err("HashMap<MyKey, u32>") {
1228 EmitError::UnsupportedShape { reason, .. } => {
1229 assert!(reason.contains("map key"), "reason was: {reason}");
1230 }
1231 other => panic!("expected UnsupportedShape, got {other:?}"),
1232 }
1233 }
1234
1235 #[test]
1236 fn container_nested_option_in_option() {
1237 let rendered = emit("Option<Option<u32>>");
1240 assert_eq!(rendered, "(number | null) | null");
1241 }
1242
1243 #[test]
1244 fn container_vec_of_options() {
1245 let rendered = emit("Vec<Option<u32>>");
1246 assert_eq!(rendered, "(number | null)[]");
1247 }
1248
1249 #[test]
1252 fn smart_pointer_box_is_transparent() {
1253 assert_eq!(emit("Box<u32>"), emit("u32"));
1254 assert_eq!(emit("Box<String>"), "string");
1255 }
1256
1257 #[test]
1258 fn smart_pointer_rc_arc_are_transparent() {
1259 assert_eq!(emit("Rc<u32>"), "number");
1260 assert_eq!(emit("Arc<String>"), "string");
1261 }
1262
1263 #[test]
1264 fn smart_pointer_cow_is_transparent() {
1265 assert_eq!(emit("Cow<'a, str>"), "string");
1267 assert_eq!(emit("Cow<'static, [u32]>"), "number[]");
1268 }
1269
1270 #[test]
1271 fn smart_pointer_pin_is_transparent() {
1272 assert_eq!(emit("Pin<Box<u32>>"), "number");
1273 }
1274
1275 #[test]
1276 fn smart_pointer_nested_peels_all_the_way() {
1277 assert_eq!(emit("Arc<Box<Vec<Option<u32>>>>"), "(number | null)[]");
1279 }
1280
1281 #[test]
1284 fn reference_amp_t_unwraps_to_owned() {
1285 assert_eq!(emit("&u32"), "number");
1286 assert_eq!(emit("&String"), "string");
1287 }
1288
1289 #[test]
1290 fn reference_amp_slice_renders_as_array() {
1291 assert_eq!(emit("&[u32]"), "number[]");
1292 assert_eq!(emit("&[String]"), "string[]");
1293 }
1294
1295 #[test]
1296 fn reference_array_renders_as_array() {
1297 assert_eq!(emit("[u8; 32]"), "number[]");
1299 }
1300
1301 #[test]
1304 fn refcell_is_rejected() {
1305 match emit_err("RefCell<u32>") {
1306 EmitError::UnsupportedShape { reason, .. } => {
1307 assert!(reason.contains("RefCell"), "reason was: {reason}");
1308 }
1309 other => panic!("expected UnsupportedShape, got {other:?}"),
1310 }
1311 }
1312
1313 #[test]
1314 fn mutex_is_rejected() {
1315 match emit_err("Mutex<u32>") {
1316 EmitError::UnsupportedShape { reason, .. } => {
1317 assert!(reason.contains("Mutex"), "reason was: {reason}");
1318 }
1319 other => panic!("expected UnsupportedShape, got {other:?}"),
1320 }
1321 }
1322
1323 #[test]
1324 fn rwlock_is_rejected() {
1325 match emit_err("RwLock<u32>") {
1326 EmitError::UnsupportedShape { reason, .. } => {
1327 assert!(reason.contains("RwLock"), "reason was: {reason}");
1328 }
1329 other => panic!("expected UnsupportedShape, got {other:?}"),
1330 }
1331 }
1332
1333 #[test]
1336 fn unknown_ident_falls_through_to_terminal() {
1337 assert_eq!(emit("Workout"), "Workout");
1340 }
1341
1342 #[test]
1343 fn multi_segment_path_collapses_to_terminal_for_now() {
1344 assert_eq!(emit("crate::models::Workout"), "Workout");
1346 }
1347
1348 #[test]
1351 fn render_type_str_matches_the_in_declaration_renderer() {
1352 let config = EmitConfig::default();
1357 for src in [
1358 "String",
1359 "u8",
1360 "Vec<Option<String>>",
1361 "HashMap<String, Vec<Node>>",
1362 "Cow<'a, str>",
1363 "chrono::DateTime<Utc>",
1364 "serde_json::Value",
1365 "()",
1366 ] {
1367 let standalone = render_type_str(src, &config).unwrap_or_else(|err| panic!("`{src}` failed: {err:?}"));
1368 assert_eq!(standalone, emit(src), "standalone render of `{src}` diverged");
1369 }
1370 }
1371
1372 #[test]
1373 fn render_type_str_tolerates_token_stream_spacing() {
1374 let config = EmitConfig::default();
1377 assert_eq!(render_type_str("Vec < String >", &config).expect("renders"), "string[]");
1378 assert_eq!(render_type_str("HashMap < String , i32 >", &config).expect("renders"), "Record<string, number>");
1379 }
1380
1381 #[test]
1382 fn render_type_str_rejects_text_that_is_not_a_type() {
1383 let config = EmitConfig::default();
1384 match render_type_str("not a type!", &config) {
1385 Err(EmitError::UnsupportedShape { reason, .. }) => {
1386 assert!(reason.contains("does not parse"), "reason was: {reason}");
1387 }
1388 other => panic!("expected a parse failure, got {other:?}"),
1389 }
1390 }
1391
1392 #[test]
1393 fn render_type_honors_config() {
1394 let config = EmitConfig { bigint_behavior: BigIntBehavior::BigInt, ..EmitConfig::default() };
1398 assert_eq!(render_type_str("u64", &config).expect("renders"), "bigint");
1399 assert_eq!(render_type_str("u64", &EmitConfig::default()).expect("renders"), "number");
1400 }
1401
1402 fn struct_item(src: &str) -> syn::ItemStruct {
1405 syn::parse_str(src).unwrap_or_else(|err| panic!("failed to parse struct `{src}`: {err}"))
1406 }
1407
1408 fn enum_item(src: &str) -> syn::ItemEnum {
1409 syn::parse_str(src).unwrap_or_else(|err| panic!("failed to parse enum `{src}`: {err}"))
1410 }
1411
1412 fn assert_fixture_matches(scenario: &str) {
1425 let manifest = env!("CARGO_MANIFEST_DIR");
1426 let rs_path = format!("{manifest}/tests/fixtures/{scenario}.rs");
1427 let ts_path = format!("{manifest}/tests/fixtures/{scenario}.ts");
1428
1429 let rs = std::fs::read_to_string(&rs_path).unwrap_or_else(|e| panic!("read {rs_path}: {e}"));
1430 let parsed: syn::File = syn::parse_str(&rs).unwrap_or_else(|e| panic!("parse {rs_path}: {e}"));
1431 let item =
1432 parsed.items.into_iter().next().unwrap_or_else(|| panic!("fixture {scenario} has no top-level item"));
1433
1434 let config = EmitConfig::default();
1435 let actual = match &item {
1436 syn::Item::Struct(s) => emit_struct(s, &config),
1437 syn::Item::Enum(e) => emit_enum(e, &config),
1438 _ => panic!("fixture {scenario} top-level item is not a struct or enum"),
1439 }
1440 .unwrap_or_else(|e| panic!("emit failed for {scenario}: {e}"));
1441
1442 if std::env::var("UPDATE_TS_FIXTURES").is_ok() {
1443 let canonical = format!("{}\n", actual.trim_end());
1445 std::fs::write(&ts_path, &canonical).unwrap_or_else(|e| panic!("write {ts_path}: {e}"));
1446 return;
1447 }
1448
1449 let expected = std::fs::read_to_string(&ts_path).unwrap_or_default();
1450 assert_eq!(
1451 actual.trim(),
1452 expected.trim(),
1453 "fixture {scenario} mismatch (run with UPDATE_TS_FIXTURES=1 to refresh)"
1454 );
1455 }
1456
1457 #[test]
1458 fn struct_named_fields_emit_export_type() {
1459 assert_fixture_matches("struct_named_fields_emit_export_type");
1460 }
1461
1462 #[test]
1463 fn struct_with_all_primitive_field_types() {
1464 assert_fixture_matches("struct_with_all_primitive_field_types");
1465 }
1466
1467 #[test]
1468 fn struct_field_ref_str() {
1469 assert_fixture_matches("struct_field_ref_str");
1470 }
1471
1472 #[test]
1473 fn struct_field_containers() {
1474 assert_fixture_matches("struct_field_containers");
1475 }
1476
1477 #[test]
1478 fn struct_field_smart_pointer_box_transparent() {
1479 assert_fixture_matches("struct_field_smart_pointer_box_transparent");
1480 }
1481
1482 #[test]
1483 fn struct_field_unknown_ident_falls_through() {
1484 assert_fixture_matches("struct_field_unknown_ident_falls_through");
1485 }
1486
1487 #[test]
1488 fn struct_empty_named_fields() {
1489 assert_fixture_matches("struct_empty_named_fields");
1490 }
1491
1492 #[test]
1493 fn struct_tuple_is_rejected() {
1494 let config = EmitConfig::default();
1495 let item = struct_item("pub struct NewType(pub u32);");
1496 match emit_struct(&item, &config).expect_err("tuple struct should fail") {
1497 EmitError::UnsupportedShape { reason, .. } => {
1498 assert!(reason.contains("tuple"), "reason was: {reason}");
1499 }
1500 other => panic!("expected UnsupportedShape, got {other:?}"),
1501 }
1502 }
1503
1504 #[test]
1505 fn struct_unit_is_rejected() {
1506 let config = EmitConfig::default();
1507 let item = struct_item("pub struct Marker;");
1508 match emit_struct(&item, &config).expect_err("unit struct should fail") {
1509 EmitError::UnsupportedShape { reason, .. } => {
1510 assert!(reason.contains("unit"), "reason was: {reason}");
1511 }
1512 other => panic!("expected UnsupportedShape, got {other:?}"),
1513 }
1514 }
1515
1516 #[test]
1517 fn struct_error_propagates_from_field_emission() {
1518 let config = EmitConfig::default();
1521 let item = struct_item(
1522 "pub struct Bad {
1523 pub locked: std::sync::Mutex<u32>,
1524 }",
1525 );
1526 let err = emit_struct(&item, &config).expect_err("Mutex field should fail");
1527 assert!(matches!(err, EmitError::UnsupportedShape { .. }));
1528 }
1529
1530 #[test]
1533 fn enum_c_style_emits_string_literal_union() {
1534 assert_fixture_matches("enum_c_style_emits_string_literal_union");
1535 }
1536
1537 #[test]
1538 fn enum_c_style_quote_style_single_default() {
1539 let config = EmitConfig::default();
1543 assert_eq!(config.quote_style, QuoteStyle::Single);
1544 let item = enum_item(
1545 "#[serde(rename_all = \"lowercase\")]
1546 pub enum Letter {
1547 A,
1548 B,
1549 }",
1550 );
1551 let ts = emit_enum(&item, &config).expect("emit ok");
1552 assert_eq!(ts, "export type Letter = 'a' | 'b';");
1553 }
1554
1555 #[test]
1556 fn enum_c_style_quote_style_double() {
1557 let config = EmitConfig { quote_style: QuoteStyle::Double, ..EmitConfig::default() };
1560 let item = enum_item(
1561 "#[serde(rename_all = \"lowercase\")]
1562 pub enum Letter {
1563 A,
1564 B,
1565 }",
1566 );
1567 let ts = emit_enum(&item, &config).expect("emit ok");
1568 assert_eq!(ts, "export type Letter = \"a\" | \"b\";");
1569 }
1570
1571 #[test]
1572 fn enum_tuple_zero_arg_variant_respects_quote_style() {
1573 let single = EmitConfig::default();
1579 let item = enum_item(
1580 "pub enum E {
1581 Foo(),
1582 }",
1583 );
1584 let ts = emit_enum(&item, &single).expect("emit ok");
1585 assert_eq!(ts, "export type E = 'Foo';");
1586
1587 let double = EmitConfig { quote_style: QuoteStyle::Double, ..EmitConfig::default() };
1588 let ts = emit_enum(&item, &double).expect("emit ok");
1589 assert_eq!(ts, "export type E = \"Foo\";");
1590 }
1591
1592 #[test]
1593 fn enum_single_variant_c_style() {
1594 assert_fixture_matches("enum_single_variant_c_style");
1595 }
1596
1597 #[test]
1598 fn enum_empty_emits_never() {
1599 assert_fixture_matches("enum_empty_emits_never");
1600 }
1601
1602 #[test]
1603 fn enum_tuple_variant_externally_tagged() {
1604 assert_fixture_matches("enum_tuple_variant_externally_tagged");
1607 }
1608
1609 #[test]
1610 fn enum_struct_variant_externally_tagged() {
1611 assert_fixture_matches("enum_struct_variant_externally_tagged");
1612 }
1613
1614 #[test]
1615 fn enum_tuple_variant_with_primitive_payload() {
1616 assert_fixture_matches("enum_tuple_variant_with_primitive_payload");
1617 }
1618
1619 #[test]
1620 fn enum_multi_field_tuple_variant_is_rejected() {
1621 let config = EmitConfig::default();
1622 let item = enum_item(
1623 "pub enum Bad {
1624 Two(u32, u32),
1625 }",
1626 );
1627 let err = emit_enum(&item, &config).expect_err("multi-tuple variant should fail");
1628 match err {
1629 EmitError::UnsupportedShape { reason, .. } => {
1630 assert!(reason.contains("tuple"), "reason was: {reason}");
1631 }
1632 other => panic!("expected UnsupportedShape, got {other:?}"),
1633 }
1634 }
1635
1636 #[test]
1637 fn enum_error_propagates_from_variant_emission() {
1638 let config = EmitConfig::default();
1639 let item = enum_item(
1640 "pub enum Bad {
1641 Locked(Mutex<u32>),
1642 }",
1643 );
1644 let err = emit_enum(&item, &config).expect_err("Mutex variant payload should fail");
1645 assert!(matches!(err, EmitError::UnsupportedShape { .. }));
1646 }
1647
1648 #[test]
1651 fn struct_rename_all_camel_case() {
1652 assert_fixture_matches("struct_rename_all_camel_case");
1653 }
1654
1655 #[test]
1656 fn struct_field_rename_wins_over_container() {
1657 assert_fixture_matches("struct_field_rename_wins_over_container");
1658 }
1659
1660 #[test]
1661 fn struct_field_serde_skip_drops_field() {
1662 assert_fixture_matches("struct_field_serde_skip_drops_field");
1663 }
1664
1665 #[test]
1666 fn struct_field_serde_default_optional() {
1667 assert_fixture_matches("struct_field_serde_default_optional");
1671 }
1672
1673 #[test]
1674 fn struct_field_rename_with_hyphen_quotes_key() {
1675 assert_fixture_matches("struct_field_rename_with_hyphen_quotes_key");
1678 }
1679
1680 #[test]
1681 fn enum_rename_all_snake_case() {
1682 assert_fixture_matches("enum_rename_all_snake_case");
1683 }
1684
1685 #[test]
1686 fn enum_variant_rename_wins_over_container() {
1687 assert_fixture_matches("enum_variant_rename_wins_over_container");
1688 }
1689
1690 #[test]
1693 fn enum_rename_all_spares_variant_fields() {
1694 assert_fixture_matches("enum_rename_all_spares_variant_fields");
1698 }
1699
1700 #[test]
1701 fn enum_rename_all_fields() {
1702 assert_fixture_matches("enum_rename_all_fields");
1705 }
1706
1707 #[test]
1708 fn enum_variant_rename_all_wins_over_container() {
1709 assert_fixture_matches("enum_variant_rename_all_wins_over_container");
1713 }
1714
1715 #[test]
1716 fn enum_variant_rename_all_does_not_touch_the_variant_key() {
1717 let config = EmitConfig::default();
1721 let item = enum_item(
1722 r#"
1723 #[serde(rename_all = "camelCase")]
1724 pub enum Event {
1725 #[serde(rename_all = "UPPERCASE")]
1726 ToolCall { prompt_template: String },
1727 }
1728 "#,
1729 );
1730 let ts = emit_enum(&item, &config).expect("emit ok");
1731 assert_eq!(ts, "export type Event = { toolCall: { PROMPT_TEMPLATE: string } };");
1732 }
1733
1734 #[test]
1735 fn config_case_default_does_not_reach_variant_fields() {
1736 let config = EmitConfig { case_default: Some(crate::types::RenameAll::CamelCase), ..Default::default() };
1741 let item = enum_item(
1742 "pub enum Event {
1743 ToolCall { prompt_template: String },
1744 }",
1745 );
1746 let ts = emit_enum(&item, &config).expect("emit ok");
1747 assert_eq!(ts, "export type Event = { toolCall: { prompt_template: string } };");
1748 }
1749
1750 #[test]
1751 fn enum_field_rename_wins_over_every_rename_all() {
1752 let config = EmitConfig::default();
1754 let item = enum_item(
1755 r#"
1756 #[serde(rename_all_fields = "camelCase")]
1757 pub enum Event {
1758 #[serde(rename_all = "UPPERCASE")]
1759 ToolCall {
1760 #[serde(rename = "tmpl")]
1761 prompt_template: String,
1762 },
1763 }
1764 "#,
1765 );
1766 let ts = emit_enum(&item, &config).expect("emit ok");
1767 assert!(ts.contains("tmpl: string"), "ts was: {ts}");
1768 }
1769
1770 #[test]
1771 fn struct_rename_all_fields_is_inert() {
1772 let config = EmitConfig::default();
1776 let item = struct_item(
1777 r#"
1778 #[serde(rename_all_fields = "camelCase")]
1779 pub struct Foo {
1780 pub prompt_template: String,
1781 }
1782 "#,
1783 );
1784 let ts = emit_struct(&item, &config).expect("emit ok");
1785 assert!(ts.contains("prompt_template: string"), "ts was: {ts}");
1786 }
1787
1788 #[test]
1789 fn enum_rename_all_fields_rejects_unknown_mode() {
1790 let config = EmitConfig::default();
1793 let item = enum_item(
1794 r#"
1795 #[serde(rename_all_fields = "Train-Case")]
1796 pub enum Event {
1797 ToolCall { prompt_template: String },
1798 }
1799 "#,
1800 );
1801 match emit_enum(&item, &config).expect_err("unknown mode should fail") {
1802 EmitError::UnsupportedSerdeAttr { attr, .. } => {
1803 assert!(attr.contains("rename_all_fields"), "attr was: {attr}");
1804 assert!(attr.contains("Train-Case"), "attr was: {attr}");
1805 }
1806 other => panic!("expected UnsupportedSerdeAttr, got {other:?}"),
1807 }
1808 }
1809
1810 #[test]
1813 fn struct_container_default_optional() {
1814 assert_fixture_matches("struct_container_default_optional");
1819 }
1820
1821 #[test]
1822 fn struct_container_default_composes_with_field_default() {
1823 let config = EmitConfig::default();
1826 let item = struct_item(
1827 r#"
1828 #[serde(default)]
1829 pub struct Settings {
1830 #[serde(default)]
1831 pub retries: u32,
1832 }
1833 "#,
1834 );
1835 let ts = emit_struct(&item, &config).expect("emit ok");
1836 assert_eq!(ts, "export type Settings = {\n retries?: number;\n};");
1837 }
1838
1839 #[test]
1840 fn struct_container_default_path_form_is_equivalent() {
1841 let config = EmitConfig::default();
1842 let item = struct_item(
1843 r#"
1844 #[serde(default = "defaults::settings")]
1845 pub struct Settings {
1846 pub retries: u32,
1847 }
1848 "#,
1849 );
1850 let ts = emit_struct(&item, &config).expect("emit ok");
1851 assert!(ts.contains("retries?: number"), "ts was: {ts}");
1852 }
1853
1854 #[test]
1855 fn struct_container_default_still_drops_skipped_fields() {
1856 let config = EmitConfig::default();
1858 let item = struct_item(
1859 r#"
1860 #[serde(default)]
1861 pub struct Settings {
1862 pub retries: u32,
1863 #[serde(skip)]
1864 pub cached: u32,
1865 }
1866 "#,
1867 );
1868 let ts = emit_struct(&item, &config).expect("emit ok");
1869 assert!(ts.contains("retries?: number"), "ts was: {ts}");
1870 assert!(!ts.contains("cached"), "ts was: {ts}");
1871 }
1872
1873 #[test]
1874 fn struct_container_default_rejects_a_flattened_field() {
1875 let config = EmitConfig::default();
1878 let item = struct_item(
1879 r#"
1880 #[serde(default)]
1881 pub struct Step {
1882 #[serde(flatten)]
1883 pub meta: StepMeta,
1884 }
1885 "#,
1886 );
1887 match emit_struct(&item, &config).expect_err("container default + flatten should be rejected") {
1888 EmitError::UnsupportedShape { reason, .. } => {
1889 assert!(reason.contains("absent-or-present"), "reason was: {reason}");
1890 assert!(reason.contains("container"), "reason was: {reason}");
1891 }
1892 other => panic!("expected UnsupportedShape, got {other:?}"),
1893 }
1894 }
1895
1896 #[test]
1897 fn enum_container_default_does_not_reach_variant_fields() {
1898 let config = EmitConfig::default();
1901 let item = enum_item(
1902 r#"
1903 #[serde(default)]
1904 pub enum Event {
1905 Move { x: u32 },
1906 }
1907 "#,
1908 );
1909 let ts = emit_enum(&item, &config).expect("emit ok");
1910 assert_eq!(ts, "export type Event = { Move: { x: number } };");
1911 }
1912
1913 #[test]
1914 fn struct_without_container_default_keeps_fields_required() {
1915 let config = EmitConfig::default();
1917 let item = struct_item(
1918 "pub struct Settings {
1919 pub retries: u32,
1920 pub notes: Option<String>,
1921 }",
1922 );
1923 let ts = emit_struct(&item, &config).expect("emit ok");
1924 assert_eq!(ts, "export type Settings = {\n retries: number;\n notes: string | null;\n};");
1925 }
1926
1927 #[test]
1930 fn struct_field_flatten_intersection() {
1931 assert_fixture_matches("struct_field_flatten_intersection");
1935 }
1936
1937 #[test]
1938 fn struct_field_flatten_only() {
1939 assert_fixture_matches("struct_field_flatten_only");
1942 }
1943
1944 #[test]
1945 fn struct_field_flatten_catch_all_map() {
1946 assert_fixture_matches("struct_field_flatten_catch_all_map");
1949 }
1950
1951 #[test]
1952 fn enum_struct_variant_flatten() {
1953 assert_fixture_matches("enum_struct_variant_flatten");
1956 }
1957
1958 #[test]
1959 fn struct_field_flatten_peels_smart_pointers() {
1960 let config = EmitConfig::default();
1962 let item = struct_item(
1963 "pub struct Step {
1964 #[serde(flatten)]
1965 pub meta: Box<StepMeta>,
1966 pub program: String,
1967 }",
1968 );
1969 let ts = emit_struct(&item, &config).expect("boxed flatten should emit");
1970 assert!(ts.starts_with("export type Step = StepMeta & {"), "ts was: {ts}");
1971 }
1972
1973 #[test]
1974 fn struct_field_flatten_respects_rename_all_on_siblings() {
1975 let config = EmitConfig::default();
1978 let item = struct_item(
1979 r#"
1980 #[serde(rename_all = "camelCase")]
1981 pub struct Step {
1982 #[serde(flatten)]
1983 pub meta: StepMeta,
1984 pub program_name: String,
1985 }
1986 "#,
1987 );
1988 let ts = emit_struct(&item, &config).expect("emit ok");
1989 assert!(ts.contains("StepMeta & {"), "ts was: {ts}");
1990 assert!(ts.contains("programName: string"), "ts was: {ts}");
1991 }
1992
1993 fn assert_flatten_rejected(field_ty: &str, needle: &str) {
1996 let config = EmitConfig::default();
1997 let item = struct_item(&format!(
1998 "pub struct Holder {{
1999 #[serde(flatten)]
2000 pub inner: {field_ty},
2001 pub tail: u32,
2002 }}"
2003 ));
2004 let Err(err) = emit_struct(&item, &config) else {
2005 panic!("flatten of `{field_ty}` should have been rejected");
2006 };
2007 match err {
2008 EmitError::UnsupportedShape { reason, .. } => {
2009 assert!(reason.contains(needle), "flatten of `{field_ty}` — reason was: {reason}");
2010 }
2011 other => panic!("expected UnsupportedShape for `{field_ty}`, got {other:?}"),
2012 }
2013 }
2014
2015 #[test]
2016 fn struct_field_flatten_rejects_option() {
2017 assert_flatten_rejected("Option<StepMeta>", "absent-or-present");
2020 assert_flatten_rejected("Box<Option<StepMeta>>", "absent-or-present");
2022 }
2023
2024 #[test]
2025 fn struct_field_flatten_rejects_non_object_renderings() {
2026 assert_flatten_rejected("String", "renders as `string`");
2029 assert_flatten_rejected("u32", "renders as `number`");
2030 assert_flatten_rejected("Vec<StepMeta>", "renders as `StepMeta[]`");
2031 assert_flatten_rejected("serde_json::Value", "renders as `unknown`");
2032 }
2033
2034 #[test]
2035 fn struct_field_flatten_rejects_default_combination() {
2036 let config = EmitConfig::default();
2037 let item = struct_item(
2038 "pub struct Holder {
2039 #[serde(flatten, default)]
2040 pub inner: StepMeta,
2041 }",
2042 );
2043 match emit_struct(&item, &config).expect_err("flatten + default should be rejected") {
2044 EmitError::UnsupportedShape { reason, .. } => {
2045 assert!(reason.contains("flatten, default"), "reason was: {reason}");
2046 }
2047 other => panic!("expected UnsupportedShape, got {other:?}"),
2048 }
2049 }
2050
2051 #[test]
2052 fn struct_field_flatten_and_skip_leave_only_flatten() {
2053 let config = EmitConfig::default();
2056 let item = struct_item(
2057 "pub struct Holder {
2058 #[serde(flatten)]
2059 pub inner: StepMeta,
2060 #[serde(skip)]
2061 pub cached: u32,
2062 }",
2063 );
2064 let ts = emit_struct(&item, &config).expect("emit ok");
2065 assert_eq!(ts, "export type Holder = StepMeta;");
2066 }
2067
2068 #[test]
2069 fn struct_rejects_split_rename_on_field() {
2070 let config = EmitConfig::default();
2071 let item = struct_item(
2072 r#"pub struct Foo {
2073 #[serde(rename(serialize = "wireName", deserialize = "WIRE_NAME"))]
2074 pub a: u32,
2075 }"#,
2076 );
2077 let err = emit_struct(&item, &config).expect_err("split-rename should fail");
2078 match err {
2079 EmitError::UnsupportedSerdeAttr { attr, .. } => {
2080 assert!(attr.contains("split-rename"), "attr was: {attr}");
2081 }
2082 other => panic!("expected UnsupportedSerdeAttr, got {other:?}"),
2083 }
2084 }
2085
2086 #[test]
2087 fn enum_rejects_tag_attr_on_container() {
2088 let config = EmitConfig::default();
2089 let item = enum_item(
2090 r#"
2091 #[serde(tag = "type")]
2092 pub enum Msg {
2093 Click,
2094 Hover,
2095 }
2096 "#,
2097 );
2098 let err = emit_enum(&item, &config).expect_err("tag-attr should fail");
2099 match err {
2100 EmitError::UnsupportedSerdeAttr { attr, .. } => {
2101 assert!(attr.contains("tag"), "attr was: {attr}");
2102 }
2103 other => panic!("expected UnsupportedSerdeAttr, got {other:?}"),
2104 }
2105 }
2106
2107 #[test]
2108 fn config_case_default_applies_when_container_has_no_rename_all() {
2109 let config = EmitConfig { case_default: Some(crate::types::RenameAll::CamelCase), ..Default::default() };
2112 let item = struct_item(
2113 "pub struct Foo {
2114 pub user_name: String,
2115 pub age_years: u32,
2116 }",
2117 );
2118 let ts = emit_struct(&item, &config).unwrap();
2119 assert!(ts.contains("userName: string"), "ts was: {ts}");
2120 assert!(ts.contains("ageYears: number"), "ts was: {ts}");
2121 }
2122
2123 #[test]
2124 fn container_rename_all_wins_over_config_case_default() {
2125 let config = EmitConfig { case_default: Some(crate::types::RenameAll::CamelCase), ..Default::default() };
2128 let item = struct_item(
2129 r#"
2130 #[serde(rename_all = "snake_case")]
2131 pub struct Foo {
2132 pub user_name: String,
2133 }
2134 "#,
2135 );
2136 let ts = emit_struct(&item, &config).unwrap();
2137 assert!(ts.contains("user_name: string"), "ts was: {ts}");
2140 assert!(!ts.contains("userName"), "ts was: {ts}");
2141 }
2142}