Skip to main content

ontogen_ts/
emit.rs

1//! Per-type and top-level emission entry points.
2//!
3//! - [`emit`] is the public entry point. It walks the type pool, resolves
4//!   names + ontogen attrs, runs Kahn's topological sort, and emits TS
5//!   for each reachable type via the per-type emitters below.
6//! - [`emit_type`] renders a `syn::Type` as TS.
7//! - [`emit_struct`] / [`emit_enum`] render a `syn::ItemStruct` /
8//!   `syn::ItemEnum`. Their `_named` siblings accept an explicit name
9//!   override (used when `#[ts_name = "..."]` is set on the type).
10
11use 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
26/// Emit TypeScript source for `roots` and everything they transitively reach
27/// in `type_pool`, honoring `config`.
28///
29/// Pipeline (PR 4):
30///
31/// 1. Build the dependency graph over `type_pool`.
32/// 2. Compute transitive closure from `roots`.
33/// 3. Resolve a TS name for each reachable type (honoring
34///    `#[ts_name = "..."]` overrides).
35/// 4. Detect name collisions on the reachable set — two types resolving
36///    to the same TS name produce [`EmitError::NameCollision`].
37/// 5. Topologically order the reachable set (cycle members co-emit at the
38///    end; TS type aliases accept forward references).
39/// 6. For each type in order:
40///    - Read ontogen attrs. If `#[ts_opaque(target = "...")]` is set, emit
41///      `export type Name = <target>;` and skip recursion into fields.
42///    - Otherwise dispatch to [`emit_struct_named`] / [`emit_enum_named`]
43///      with the resolved name.
44///
45/// All errors are collected into `Vec<EmitError>` before failing — never
46/// first-error fail-fast, so a build surfaces every problem at once.
47///
48/// This convenience entry point resolves bare single-segment references
49/// without per-module `use` tables (same-module and unique-terminal matching
50/// only). To resolve references that come in through cross-module `use`
51/// imports — including multi-level re-export chains — call
52/// [`emit_with_imports`] with the [`ModuleImports`] returned by
53/// [`crate::pool::scan_src_dir_with_imports`].
54pub 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
62/// Like [`emit`], but resolves bare single-segment references through
63/// `imports` — each referencing module's `use` table — so a type pulled in
64/// via `use` (possibly through several re-export hops) links to the right
65/// pool key even when several modules define same-terminal types.
66pub 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    // 1-2: dep graph + reachable closure.
75    let graph = order::dependency_graph_with_imports(type_pool, imports);
76    let reachable = order::reachable_from(roots, &graph);
77
78    // Surface any root that isn't in the pool — caller passed a TypePath
79    // we can't emit. Hard error; the build can't produce a meaningful
80    // output without it.
81    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    // 3: resolve names. Walk reachable items; if extract_ontogen_attrs
91    // surfaces an EmitError (malformed attr), collect it and use the
92    // ident-derived fallback name.
93    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    // 4: name-collision detection (post-`ts_name` resolution).
112    {
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    // 5: topological order.
125    let ordered = order::topo_order(&graph, &reachable);
126
127    // 6: per-type emission.
128    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, // already collected above
137        };
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                // Type alias: emit as `export type Name = <inner_ts>;`.
156                // The walker would normally recurse, but for a top-level
157                // alias the surface is the inner type directly.
158                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                // Pool walker only inserts struct/enum/type aliases, so
166                // this branch shouldn't fire in practice.
167            }
168        }
169    }
170
171    if !errors.is_empty() {
172        return Err(errors);
173    }
174
175    Ok(outputs.join("\n\n"))
176}
177
178/// Pull the attribute list off any `syn::Item` shape the pool stores.
179fn 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
188/// Diagnostic stand-in for the "type whose field we're rendering" when a type
189/// is rendered on its own, outside any declaration. See [`render_type`].
190const STANDALONE: &str = "<standalone type>";
191
192/// Synthetic [`TypePath`] used as `referenced_by` for standalone renders.
193fn standalone_path() -> TypePath {
194    TypePath::new(vec![STANDALONE.to_string()]).expect("non-empty")
195}
196
197/// Render one Rust type as TypeScript, with no type pool and no surrounding
198/// declaration.
199///
200/// This is the same classifier [`emit`] uses for struct fields and enum
201/// payloads — smart-pointer peeling, container generics, primitives, and the
202/// external-types table — exposed for callers that hold a single type rather
203/// than a pool of declarations. User-defined types render as their terminal
204/// ident, exactly as they do inside a declaration; resolving that ident to a
205/// definition is the caller's business.
206///
207/// ontogen's API-signature emitter (`rust_type_to_ts`) delegates here, which
208/// is what keeps the two TypeScript emitters from disagreeing on a shape they
209/// both have to render into the same generated file.
210pub fn render_type(ty: &Type, config: &EmitConfig) -> Result<String, EmitError> {
211    emit_type(ty, config, &standalone_path())
212}
213
214/// Parse `rust_ty` as a Rust type expression and render it via
215/// [`render_type`].
216///
217/// For callers whose type arrived as text — a rendered token stream, a
218/// signature scraped from source — rather than as a `syn::Type`. Spacing is
219/// whatever `syn` tolerates, so token-stream renderings like `Vec < String >`
220/// parse fine.
221///
222/// Returns [`EmitError::UnsupportedShape`] if the text doesn't parse as a
223/// type.
224pub 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
232/// Render a `syn::Type` as its TypeScript equivalent.
233///
234/// Classification order (matches the OF-015 design pass):
235///
236/// 1. **Smart-pointer peel** — `Box<T>`, `Rc<T>`, `Arc<T>`, `Cow<'_, T>`,
237///    `Pin<P>` are stripped and the inner type is re-classified. All five
238///    are transparent to `serde_json` at runtime.
239/// 2. **Runtime-coordination rejection** — `RefCell<T>`, `Mutex<T>`,
240///    `RwLock<T>` produce [`EmitError::UnsupportedShape`]. These shouldn't
241///    appear in wire types.
242/// 3. **Container generics** — `Option<T>` → `T | null`, `Vec<T>` → `T[]`,
243///    `HashMap<K, V>` / `BTreeMap<K, V>` → `Record<K, V>` (key validated as
244///    `String` or id-like primitive).
245/// 4. **Reference types** — `&T` recurses on `T`; `&[T]` recurses as
246///    `Vec<T>`; `&str` lands on the `str` primitive path which renders as
247///    `string`.
248/// 5. **Primitives** — `bool` → `boolean`; integer types → `number` (or
249///    `bigint`/`string` for 64-bit ints if [`EmitConfig::bigint_behavior`]
250///    requests it); `f32`/`f64` → `number`; `String`/`str` → `string`.
251/// 6. **Fall-through** — anything else (a user-defined struct/enum ident)
252///    is rendered as the terminal ident verbatim. PR 3 replaces this with
253///    real pool / external-types lookup; the placeholder here lets per-type
254///    unit tests run without the full walking infrastructure.
255///
256/// `referenced_by` names the type whose field we're classifying — it
257/// surfaces in `EmitError`s for diagnostic context. Phase 1 doesn't have a
258/// "synthetic path" mechanism, so unit tests pass a contrived single-segment
259/// path.
260pub(crate) fn emit_type(ty: &Type, config: &EmitConfig, referenced_by: &TypePath) -> Result<String, EmitError> {
261    // 1. Peel smart-pointer wrappers before any further classification.
262    if let Some(inner) = peel_smart_pointer(ty) {
263        return emit_type(inner, config, referenced_by);
264    }
265
266    // 4a. References — `&T` recurses on `T`; `&[T]` becomes `Vec<T>`-ish.
267    if let Type::Reference(TypeReference { elem, .. }) = ty {
268        // `&[T]` → render like `Vec<T>` for wire equivalence.
269        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    // `[T; N]` is treated like a slice — same wire shape (a JSON array).
277    if let Type::Array(TypeArray { elem, .. }) = ty {
278        let inner = emit_type(elem, config, referenced_by)?;
279        return Ok(format!("{inner}[]"));
280    }
281
282    // Bare `[T]` — can show up as the inner of a peeled `Cow<'a, [T]>`. Same
283    // wire shape as `Vec<T>`.
284    if let Type::Slice(TypeSlice { elem, .. }) = ty {
285        let inner = emit_type(elem, config, referenced_by)?;
286        return Ok(format!("{inner}[]"));
287    }
288
289    // The unit type is `null` on the wire — serde serializes `()` as JSON
290    // `null`. It reaches here from handler signatures that return nothing.
291    // Non-empty tuples stay unsupported: they serialize as JSON arrays, but
292    // TS tuple syntax needs an element-by-element rendering that phase 1's
293    // field-name-driven model has no place for.
294    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    // Everything else lives on a `syn::TypePath`.
305    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    // 2. Runtime-coordination wrappers are rejected hard. Match on terminal
316    // ident regardless of whether the user wrote generic args explicitly.
317    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    // 3. Container generics with hardcoded TS renderings.
330    if let Some(container) = match_container(path) {
331        return emit_container(container, config, referenced_by);
332    }
333
334    // 5. Primitives by terminal ident.
335    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    // 6. Fall-through — check the external-types table first, then the
342    // terminal ident as a last resort. PR 3 added `crate::external` for
343    // the lookup; PR 4 wires it here. Multi-segment paths like
344    // `chrono::DateTime<Utc>` strip generic args and consult the table,
345    // returning `"string"` (the default for chrono::DateTime). Anything
346    // not in the table falls back to the terminal ident, leaving the
347    // top-level `emit` composition to look it up in the type pool.
348    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    // Strip leading `crate::` so external-types lookup uses canonical form
357    // (the table keys are full canonical paths like `chrono::DateTime`).
358    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    // Final fall-through: the terminal ident verbatim. emit's top-level
369    // composition handles pool lookup at the type-declaration level;
370    // here we just emit the name and trust that downstream renders cover
371    // the rest.
372    Ok(segments.last().expect("non-empty after the early return above").clone())
373}
374
375/// Emit a `syn::ItemStruct` as a TypeScript `export type Name = { ... };`
376/// declaration.
377///
378/// Only named-field structs are supported in phase 1. Tuple structs
379/// (`struct Foo(u32, u32)`) and unit structs (`struct Bar;`) return
380/// [`EmitError::UnsupportedShape`] — the OF-014 spike survey showed neither
381/// shape carries enough name information to round-trip cleanly through TS
382/// without inventing field names. Users can wrap in a named-field struct or
383/// reach for `#[ontogen::ts_opaque]`.
384///
385/// Serde renames ARE applied as of PR 2. Precedence (matching serde's own
386/// rules so wire names round-trip through `serde_json::to_string`):
387///
388/// 1. `#[serde(rename = "wireName")]` on the field — wins outright.
389/// 2. `#[serde(rename_all = "...")]` on the container — applied to the field
390///    ident.
391/// 3. `EmitConfig::case_default` — applied to the field ident if neither of
392///    the above is set.
393/// 4. Field ident verbatim.
394///
395/// `#[serde(skip)]` (and the `skip_serializing` / `skip_deserializing` siblings)
396/// drops the field entirely.
397///
398/// `#[serde(default)]` (bare or the `default = "path"` form) renders the field
399/// as TS-optional (`field?: T`) — the deserializer accepts partial JSON for
400/// the field, so the emitted contract matches the wire. It composes with
401/// `Option<T>` → `T | null` to produce `field?: T | null`.
402///
403/// The same attribute on the *container* fills every absent field from the
404/// struct's `Default`, so it marks every field optional — a struct that
405/// accepts `{}` on the wire emits a TS type whose properties are all `?`.
406///
407/// `#[serde(flatten)]` splices the field type's keys into the parent object
408/// instead of nesting them under the field name, so the field becomes a TS
409/// intersection member rather than a property:
410///
411/// ```text
412/// #[serde(flatten)] meta: StepMeta,   →   export type Step =
413/// program: String,                        StepMeta & { program: string };
414/// ```
415///
416/// Flattened members are emitted in field-declaration order ahead of the
417/// property object; if every field is flattened, the (empty) object is
418/// dropped and the type is the bare intersection. See [`flatten_member`] for
419/// which field types are admissible.
420#[allow(dead_code)] // tests-only convenience wrapper; production calls _named directly.
421pub(crate) fn emit_struct(item: &ItemStruct, config: &EmitConfig) -> Result<String, EmitError> {
422    emit_struct_named(item, config, None)
423}
424
425/// Emit a struct with an optional TS name override (used by the top-level
426/// composition when `#[ts_name = "..."]` is present on the type).
427pub(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            // `struct Foo {}` — or all fields skipped/flattened. `None` here
444            // means "no property object at all": with flattened members it
445            // drops out of the intersection, without them it renders `{}`
446            // rather than multi-line empties.
447            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
472/// A named-field group — a struct body or an enum struct-variant body —
473/// split into the two pieces TypeScript renders differently.
474struct NamedFields {
475    /// TS type expressions contributed by `#[serde(flatten)]` fields, in
476    /// declaration order. Empty in the common case, which is what keeps
477    /// non-flatten output byte-identical to the pre-flatten emitter.
478    intersections: Vec<String>,
479    /// `(ts_key, optional_marker, ts_type)` for each surviving property.
480    properties: Vec<(String, &'static str, String)>,
481}
482
483/// Classify each field of a named-field group into a flattened intersection
484/// member or an ordinary property, applying `#[serde(skip)]`, the rename
485/// family, and `#[serde(default)]` along the way.
486///
487/// `container_default` is `#[serde(default)]` on the struct itself. Serde
488/// fills every absent field from the struct's `Default`, so it makes the
489/// whole body optional — each field is treated exactly as if it carried its
490/// own `#[serde(default)]`.
491fn 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        // Either scope of `default` makes this field absent-able on the wire.
505        let defaulted = field_attrs.default || container_default;
506        if field_attrs.flatten {
507            // A flattened field's own name never reaches the wire, so the
508            // rename family is moot here — serde ignores it too.
509            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        // A defaulted field may be absent on the wire — the deserializer
517        // fills in a default. Emit it as TS-optional. Composes with
518        // `Option<T>` → `T | null` to give `field?: T | null` for an
519        // optional, nullable field.
520        let opt = if defaulted { "?" } else { "" };
521        out.properties.push((key, opt, ty_ts));
522    }
523    Ok(out)
524}
525
526/// Fold flattened intersection `members` together with the group's property
527/// `object` (already rendered; `None` when the group has no properties).
528///
529/// With no flattened members this returns the object unchanged — or `{}` for
530/// an empty group — so output for the overwhelmingly common case is exactly
531/// what the emitter produced before `flatten` was supported. `A & {}` is
532/// just `A`, so an empty object drops out of a non-empty intersection.
533fn 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
542/// Render the TS intersection member contributed by a `#[serde(flatten)]`
543/// field, or reject the field with a hard error.
544///
545/// Serde splices the flattened type's keys into the parent object, and the
546/// structural equivalent in TypeScript is an intersection. That only works
547/// when the field type renders to something object-shaped, so everything
548/// else is rejected rather than silently emitted:
549///
550/// - **`Option<T>`, and `#[serde(flatten, default)]`** — both make the whole
551///   flattened group absent-or-present as a unit. An intersection can't say
552///   that, and `Partial<T>` would wrongly admit any subset of the keys.
553/// - **`Vec<T>` / sets / primitives / `serde_json::Value`** — these render
554///   as `T[]`, `string`, `unknown`, and so on. `X & unknown` is a silent
555///   no-op and `X & string` collapses to `never`, so either way the emitted
556///   type would stop describing the wire.
557///
558/// String-keyed maps ARE admissible, which covers serde's catch-all idiom:
559/// `#[serde(flatten)] extra: HashMap<String, Value>` renders as
560/// `Record<string, unknown>` and intersects correctly.
561///
562/// Smart-pointer wrappers are peeled first — `Box<StepMeta>` flattens
563/// exactly like `StepMeta` does, since serde sees through both.
564///
565/// One case this can't catch: flattening a field whose type is an *enum*.
566/// `emit_type` only yields the referenced name, not its definition, so the
567/// emitter can't tell `StepMeta` (struct) from `StepKind` (enum) here. The
568/// resulting intersection is well-formed TS that resolves to `never`, which
569/// surfaces at the consumer's `tsc` rather than at emit time.
570fn 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
617/// TS keywords that never denote a useful intersection member for a
618/// flattened field. `object` is included: it's nominally an object type but
619/// carries no keys, so `X & object` says nothing about the wire.
620const 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
635/// True iff `rendered` is a TS type expression that can meaningfully take
636/// part in an object intersection — a `Record<...>` mapped type, or a named
637/// type reference. Unions (`T | null`), arrays (`T[]`) and the primitive
638/// keywords all fail, since `is_valid_ts_ident` rejects the first two and
639/// [`NON_OBJECT_TS_KEYWORDS`] the third.
640fn 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
647/// Compute the on-the-wire name for a struct field given the field's serde
648/// attrs and the container's effective rename_all mode.
649fn 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
659/// Pick the rename mode that governs the FIELD names inside a struct
660/// variant.
661///
662/// Serde keeps two independent axes on an enum, and conflating them emits TS
663/// that disagrees with the wire in a way that still looks plausible:
664///
665/// | attribute                              | renames                       |
666/// |----------------------------------------|-------------------------------|
667/// | `#[serde(rename_all)]` on the enum     | the variants                  |
668/// | `#[serde(rename_all_fields)]` on the enum | fields of every struct variant |
669/// | `#[serde(rename_all)]` on a variant    | fields of that variant        |
670///
671/// So the enum's own `rename_all` is deliberately absent here — it governs
672/// the variant key and nothing else. The closer scope wins between the
673/// remaining two.
674///
675/// [`EmitConfig::case_default`] is deliberately absent too. It stands in for
676/// "this crate annotates its types with `rename_all`", and a crate that does
677/// exactly that still gets verbatim struct-variant field names out of serde
678/// unless it also writes `rename_all_fields`. Folding `case_default` in here
679/// would reintroduce the same mismatch from the config side.
680fn variant_field_rename_all(container: &ContainerAttrs, variant: &VariantAttrs) -> Option<RenameAll> {
681    variant.rename_all.or(container.rename_all_fields)
682}
683
684/// Compute the on-the-wire name for an enum variant given its serde attrs
685/// and the container's effective rename_all mode.
686fn 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
696/// Render `name` as a TypeScript object-literal key. Bare-ident if it's a
697/// valid JS/TS identifier, double-quoted-string otherwise.
698fn format_ts_key(name: &str) -> String {
699    if is_valid_ts_ident(name) {
700        name.to_string()
701    } else {
702        // Quote-escape: backslash and double-quote get escaped; other JSON
703        // escapes aren't necessary because serde rename targets in practice
704        // are well-behaved ASCII strings.
705        let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
706        format!("\"{escaped}\"")
707    }
708}
709
710/// Render `s` as a TS string literal using the [`QuoteStyle`] declared on
711/// `config`. Single-quoted by default; consumers wanting double-quoted
712/// output (e.g. to match Prettier's default or pre-ontogen-ts specta
713/// emission) flip [`EmitConfig::quote_style`] to [`QuoteStyle::Double`].
714///
715/// Centralizes the one-place-to-change for every quoted-literal emit site
716/// in the emitter — today that's enum variant wire names in string-literal
717/// unions. Wire names are bare-ident-shaped in the supported phase-1
718/// subset (or `#[serde(rename = "...")]` outputs that we already format
719/// via [`format_ts_key`] for keys); embedded matching quote characters
720/// don't appear in practice, so no escaping is performed here. If a
721/// future phase admits arbitrary user-controlled strings into a literal
722/// position, escaping belongs here.
723fn quote(config: &EmitConfig, s: &str) -> String {
724    let d = config.quote_style.delimiter();
725    format!("{d}{s}{d}")
726}
727
728/// True iff `s` is a valid TypeScript identifier (ASCII subset — `[A-Za-z_$]`
729/// followed by `[A-Za-z0-9_$]*`). Conservative — strict TS allows more
730/// Unicode in idents but for serde rename targets the ASCII subset is the
731/// realistic surface.
732fn 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/// Emit a `syn::ItemEnum` as a TypeScript union type.
744///
745/// Variant shape determines the rendering:
746///
747/// - **All variants C-style (no payload)** — emits a string-literal union:
748///   `export type Color = 'Red' | 'Green' | 'Blue';`
749/// - **One or more variants carry a payload** — emits the externally-tagged
750///   shape that `serde_json` produces by default for non-`#[serde(tag)]`
751///   enums:
752///   `export type Msg = { Click: ClickPayload } | { Hover: HoverPayload }
753///   | 'Ping';` (where `'Ping'` is the C-style variant).
754///
755/// Externally-tagged is the right default because `serde_json` emits
756/// `{"VariantName": payload}` for variant-with-payload values when no
757/// `#[serde(tag = "...")]` is set. Internally / adjacently / untagged enum
758/// representations are phase-2 work (gated behind `#[serde(tag)]` /
759/// `#[serde(untagged)]` — PR 2 rejects these attrs, full support is OF-015
760/// phase 2).
761///
762/// Empty enums (`enum Foo {}`) emit as `never` since they have no
763/// inhabitants — matches `serde_json::to_string`'s effective behavior
764/// (calling code can't ever construct a value).
765///
766/// Struct variants run the same named-field collector as
767/// [`emit_struct_named`], so `#[serde(flatten)]` and `#[serde(default)]`
768/// behave identically inside a variant payload: a flattened field becomes an
769/// intersection member on the payload (`{ Move: Base & { x: number } }`) and
770/// a defaulted field becomes TS-optional.
771///
772/// Renaming inside a struct variant follows a *different* policy from the
773/// variant key — the enum's `rename_all` renames variants only, never their
774/// fields. See [`variant_field_rename_all`] for the full precedence table.
775#[allow(dead_code)] // tests-only convenience wrapper; production calls _named directly.
776pub(crate) fn emit_enum(item: &ItemEnum, config: &EmitConfig) -> Result<String, EmitError> {
777    emit_enum_named(item, config, None)
778}
779
780/// Emit an enum with an optional TS name override.
781pub(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                // C-style — string-literal variant. Quote style follows
808                // `config.quote_style` (single by default; consumers flip
809                // to double via `EmitConfig::quote_style`). Bare-ident wire
810                // names need no escaping; non-ident ones still fit inside
811                // the chosen delimiter (TS string literal syntax accepts
812                // them).
813                variant_lines.push(quote(config, &wire_name));
814            }
815            Fields::Unnamed(fields) => {
816                // Tuple-style variant. Serde's default external-tag emission
817                // wraps a single payload as `{"V": payload}` and a multi-arg
818                // tuple as `{"V": [a, b, c]}`. Phase-1 supports the
819                // single-payload case; multi-arg tuple variants are rejected
820                // as unsupported shape (users can refactor into a struct
821                // variant for clarity).
822                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                // Struct-style variant: serde emits `{"V": {field1: ..., field2: ...}}`.
843                //
844                // The variant KEY is renamed by the enum's `rename_all`
845                // (already applied above, in `wire_name`). The variant's
846                // FIELD names are a separate axis that the enum's
847                // `rename_all` does not touch — see `variant_field_rename_all`.
848                let key = format_ts_key(&wire_name);
849                let field_rename_all = variant_field_rename_all(&container, &variant_attrs);
850                // No container-default to inherit: serde rejects
851                // `#[serde(default)]` on an enum outright, so a variant body
852                // is only optional field-by-field.
853                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 every variant was `#[serde(skip)]`, fall back to `never`.
870    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
878/// Wrapper types that are silently peeled before re-classification.
879const SMART_POINTERS: &[&str] = &["Box", "Rc", "Arc", "Cow", "Pin"];
880
881/// If `ty` is a single-arg generic wrapper in [`SMART_POINTERS`], return its
882/// inner type. `Cow<'a, T>` skips the lifetime arg and returns `T`.
883fn 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    // Look for the first type-typed argument. `Cow<'a, T>` has a lifetime
896    // first, so we skip non-type args. `Pin<P>` and `Box<T>` etc. have a
897    // single type arg.
898    args.args.iter().find_map(|arg| match arg {
899        GenericArgument::Type(inner) => Some(inner),
900        _ => None,
901    })
902}
903
904/// Terminal ident of a path (e.g. `Mutex` from `std::sync::Mutex<T>`).
905/// Returns `None` for paths with a `qself`.
906fn 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
913/// True iff `path` is a single-segment ident with no generics. Returns the
914/// ident as an owned `String`.
915fn 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
929/// Container shape — one of the hardcoded phase-1 generics.
930enum Container<'a> {
931    /// `Option<T>`.
932    Option(&'a Type),
933    /// `Vec<T>`.
934    Vec(&'a Type),
935    /// `HashMap<K, V>` or `BTreeMap<K, V>`.
936    Map(&'a Type, &'a Type),
937    /// `HashSet<T>` or `BTreeSet<T>` — same wire shape as `Vec<T>`.
938    Set(&'a Type),
939}
940
941/// Match `path` against the hardcoded container generics and return the
942/// classified shape if applicable.
943fn 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        // `VecDeque` shares `Vec`'s wire shape — serde serializes it as a
965        // JSON array. Without it here the type fell through to the terminal
966        // ident and emitted a bare `VecDeque`, which is not a TS type.
967        ("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
974/// Render a classified container.
975fn 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            // Wrap union shapes in parens to keep `T | null` unambiguous if T
984            // itself happens to be a union (e.g. nested `Option<Option<T>>`,
985            // which `serde_json` flattens but the schema-known emitter
986            // preserves — phase-1 here renders the naive shape).
987            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            // Array element types that contain `|` need parens to bind
992            // tightly with the `[]` postfix.
993            if rendered.contains(" | ") { Ok(format!("({rendered})[]")) } else { Ok(format!("{rendered}[]")) }
994        }
995        Container::Map(key, value) => {
996            // TS `Record<K, V>` only accepts string-like / number-like /
997            // symbol keys. Validate the key type renders as `string` or
998            // `number`. Anything else is rejected.
999            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
1015/// True iff the rendered key type is acceptable as a TS `Record<K, V>` key.
1016fn is_record_key_renderable(rendered: &str) -> bool {
1017    // `string` covers `String`/`&str`. `number` covers all integer + float
1018    // types; `bigint` is accepted by TS as a record key as of TS 4.4+.
1019    matches!(rendered, "string" | "number" | "bigint")
1020}
1021
1022/// Map a primitive Rust ident to its TS rendering. Returns `None` for
1023/// non-primitives (callers fall through to pool / external-types lookup).
1024fn primitive_ts(name: &str, config: &EmitConfig) -> Option<&'static str> {
1025    match name {
1026        "bool" => Some("boolean"),
1027        // 64-bit-ish integers route through BigIntBehavior. `usize`/`isize`
1028        // are platform-dependent but treated as 64-bit for safety.
1029        "u64" | "i64" | "u128" | "i128" | "usize" | "isize" => Some(bigint_rendering(config.bigint_behavior)),
1030        // ≤32-bit integers and floats always fit `number`.
1031        "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => Some("number"),
1032        // `char` serializes to a single-codepoint JSON string by default.
1033        "char" => Some("string"),
1034        // `String` and string slices — `&str` reaches us via the reference
1035        // arm above, but its inner type is `str` (a bare ident), which we
1036        // catch here.
1037        //
1038        // The remaining entries cover the rest of std's string-like family:
1039        // owned `PathBuf` / `OsString` / `CString` and their unsized borrow
1040        // forms `Path` / `OsStr` / `CStr`. All six serde-serialize as JSON
1041        // strings on the wire, so they render as TS `string`. Matching on
1042        // the terminal ident (rather than the full canonical path) catches
1043        // the common `use std::path::PathBuf;` + bare-name reference; the
1044        // multi-segment forms (`std::path::PathBuf`, etc.) are also covered
1045        // in `crate::external::DEFAULT_EXTERNAL_TYPES`.
1046        "String" | "str" | "PathBuf" | "Path" | "OsString" | "OsStr" | "CString" | "CStr" => Some("string"),
1047        _ => None,
1048    }
1049}
1050
1051/// TS rendering for 64-bit integer types given the configured behavior.
1052fn 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    /// Convenience: build a single-segment `TypePath` for `referenced_by`.
1066    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    // ── Primitives ──────────────────────────────────────────────────────
1085
1086    #[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        // `&str` reaches `str` via the reference arm.
1126        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        // Owned variants — common in DTO field positions.
1137        assert_eq!(emit("PathBuf"), "string");
1138        assert_eq!(emit("OsString"), "string");
1139        assert_eq!(emit("CString"), "string");
1140        // Unsized borrow forms — show up via Option<&Path>, etc.
1141        assert_eq!(emit("Path"), "string");
1142        assert_eq!(emit("OsStr"), "string");
1143        assert_eq!(emit("CStr"), "string");
1144        // Reference forms compose with the reference arm.
1145        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        // The fall-through arm canonicalizes multi-segment paths against
1153        // the external-types table — confirm the `std::path::PathBuf`
1154        // (etc.) entries resolve to `string`.
1155        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    // ── Containers ──────────────────────────────────────────────────────
1164
1165    #[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        // `VecDeque` serializes as a JSON array like `Vec`, but was missing
1186        // from the container table, so it fell through to the terminal ident
1187        // and emitted a bare `VecDeque` — not a TS type.
1188        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        // serde serializes `()` as JSON `null`.
1195        assert_eq!(emit("()"), "null");
1196        // Degenerate but well-defined: TS collapses the duplicate itself.
1197        assert_eq!(emit("Option<()>"), "null | null");
1198    }
1199
1200    #[test]
1201    fn non_empty_tuple_is_rejected() {
1202        // A JSON array with positional meaning has no field names to hang a
1203        // TS object shape on; a named struct is the supported spelling.
1204        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        // A user-defined struct used as a key falls through emit_type to its
1226        // terminal-ident rendering, which isn't acceptable as a Record key.
1227        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        // Naive phase-1 rendering — schema-known emitter handles the
1238        // `Option<Option<T>>` flattening separately.
1239        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    // ── Smart-pointer peel ──────────────────────────────────────────────
1250
1251    #[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        // `Cow<'a, str>` — the lifetime gets skipped.
1266        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        // Arc<Box<Vec<Option<u32>>>> — every wrapper transparent.
1278        assert_eq!(emit("Arc<Box<Vec<Option<u32>>>>"), "(number | null)[]");
1279    }
1280
1281    // ── References ─────────────────────────────────────────────────────
1282
1283    #[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        // `[u8; 32]` — fixed-size arrays share Vec's wire shape.
1298        assert_eq!(emit("[u8; 32]"), "number[]");
1299    }
1300
1301    // ── Runtime-coordination wrappers ──────────────────────────────────
1302
1303    #[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    // ── Fall-through ────────────────────────────────────────────────────
1334
1335    #[test]
1336    fn unknown_ident_falls_through_to_terminal() {
1337        // Custom user struct — phase 1 emits the terminal ident as-is. PR 3
1338        // replaces this with pool / external-types lookup.
1339        assert_eq!(emit("Workout"), "Workout");
1340    }
1341
1342    #[test]
1343    fn multi_segment_path_collapses_to_terminal_for_now() {
1344        // PR 3's canonicalization will replace this with a real lookup.
1345        assert_eq!(emit("crate::models::Workout"), "Workout");
1346    }
1347
1348    // ── Standalone rendering (`render_type` / `render_type_str`) ───────
1349
1350    #[test]
1351    fn render_type_str_matches_the_in_declaration_renderer() {
1352        // `render_type_str` is the entry point ontogen's API-signature
1353        // emitter delegates to. It has to agree with what the same type
1354        // renders as inside a struct field, or the two halves of one
1355        // generated file disagree — which is the whole reason it exists.
1356        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        // Callers hand it text rendered from a token stream, which carries
1375        // spaces the source spelling never had.
1376        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        // The config reaches the standalone path too — otherwise a consumer
1395        // setting `bigint_behavior` would get it applied to the long-tail
1396        // types and silently not to the signatures.
1397        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    // ── Struct emission ────────────────────────────────────────────────
1403
1404    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    /// Drive a positive-case emission test from `tests/fixtures/<scenario>.{rs,ts}`.
1413    ///
1414    /// The `.rs` file holds exactly one `pub struct` or `pub enum` declaration
1415    /// (no `use`, no `mod`, no surrounding code). The `.ts` file holds the
1416    /// expected emitted TypeScript output, IDE-readable (no YAML wrapping).
1417    ///
1418    /// Both sides are `trim`-compared so a single trailing newline in the `.ts`
1419    /// fixture (the canonical POSIX-friendly form) doesn't trip the assert.
1420    ///
1421    /// Setting `UPDATE_TS_FIXTURES=1` regenerates the `.ts` files in place
1422    /// (with a single trailing newline) and skips the assertion. Run twice in
1423    /// a row and the working tree should stay clean.
1424    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            // Canonical form on disk: trimmed body plus a single trailing newline.
1444            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        // A `Mutex<u32>` field should cause emit_struct to surface the
1519        // UnsupportedShape error.
1520        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    // ── Enum emission ──────────────────────────────────────────────────
1531
1532    #[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        // AC-1 (default arm): the existing single-quoted shape is preserved
1540        // under `EmitConfig::default()` with the `#[serde(rename_all)]`
1541        // transform applied.
1542        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        // AC-1 (opt-in arm): flipping `quote_style` to `Double` swaps
1558        // delimiters without touching anything else.
1559        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        // The `Fields::Unnamed` 0-arg arm goes through the same `quote()`
1574        // helper as the `Fields::Unit` arm — assert both delimiter branches
1575        // there too. `Foo()` (tuple with no inner types) is unusual but
1576        // syntactically valid and the emitter renders it as a string
1577        // literal of the variant name.
1578        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        // Default serde emission for a tuple-payload variant is the
1605        // externally-tagged shape: `{"V": payload}`.
1606        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    // ── Serde renames (PR 2) ───────────────────────────────────────────
1649
1650    #[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        // `#[serde(default)]` (bare and path form) renders TS-optional `?`;
1668        // composes with `Option<T>` for `field?: T | null`. A plain `Option<T>`
1669        // without default stays required (`field: T | null`).
1670        assert_fixture_matches("struct_field_serde_default_optional");
1671    }
1672
1673    #[test]
1674    fn struct_field_rename_with_hyphen_quotes_key() {
1675        // `kebab-case` mode produces field names with `-`, which aren't valid
1676        // TS identifiers; they get quoted as object-literal keys.
1677        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    // ── Struct-variant field renaming (issue #133) ─────────────────────
1691
1692    #[test]
1693    fn enum_rename_all_spares_variant_fields() {
1694        // The repro from issue #133: an enum's `rename_all` renames the
1695        // VARIANT (`toolCall`) and must leave the variant's field names
1696        // alone, because serde does.
1697        assert_fixture_matches("enum_rename_all_spares_variant_fields");
1698    }
1699
1700    #[test]
1701    fn enum_rename_all_fields() {
1702        // `rename_all_fields` is the attribute that actually asks for the
1703        // renaming the emitter used to do unprompted.
1704        assert_fixture_matches("enum_rename_all_fields");
1705    }
1706
1707    #[test]
1708    fn enum_variant_rename_all_wins_over_container() {
1709        // A variant's own `rename_all` governs that variant's fields and
1710        // overrides the container's `rename_all_fields`; sibling variants
1711        // still follow the container.
1712        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        // Easy to get backwards: `rename_all` on a VARIANT renames that
1718        // variant's fields, not the variant's own wire name. The key here
1719        // comes from the container's `rename_all` alone.
1720        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        // `case_default` stands in for "this crate annotates with
1737        // rename_all". Such a crate still gets verbatim struct-variant field
1738        // names out of serde, so applying it here would recreate #133 from
1739        // the config side. It must still rename the variant key.
1740        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        // Closest scope of all: an explicit field rename.
1753        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        // `rename_all_fields` is an enum attribute. A struct's own fields are
1773        // governed by its `rename_all`, and the emitter must not let the
1774        // enum-only attr bleed into the struct path.
1775        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        // The error must name `rename_all_fields`, not `rename_all`, or the
1791        // build log points at the wrong attribute.
1792        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    // ── Container-level serde(default) ─────────────────────────────────
1811
1812    #[test]
1813    fn struct_container_default_optional() {
1814        // `#[serde(default)]` on the struct makes every field absent-able on
1815        // the wire, so every field is TS-optional. Emitting them as required
1816        // forced callers to spell out a value for each one to satisfy `tsc`,
1817        // even though serde accepts `{}`.
1818        assert_fixture_matches("struct_container_default_optional");
1819    }
1820
1821    #[test]
1822    fn struct_container_default_composes_with_field_default() {
1823        // Both scopes say the same thing; the field must not end up with two
1824        // `?` markers.
1825        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        // `skip` wins: the field isn't on the wire at all, optional or not.
1857        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        // Same absent-or-present problem as `#[serde(flatten, default)]`, just
1876        // inherited from the container instead of written on the field.
1877        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        // Serde rejects `#[serde(default)]` on an enum, so it must not leak
1899        // into struct-variant bodies via the shared container extractor.
1900        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        // The no-attribute path must be untouched.
1916        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    // ── serde(flatten) → TS intersection ───────────────────────────────
1928
1929    #[test]
1930    fn struct_field_flatten_intersection() {
1931        // The repro from issue #132: the flattened field's keys land in the
1932        // parent object on the wire, so TS gets an intersection, not a
1933        // nested `meta` property.
1934        assert_fixture_matches("struct_field_flatten_intersection");
1935    }
1936
1937    #[test]
1938    fn struct_field_flatten_only() {
1939        // Every field flattened — the empty property object drops out
1940        // rather than emitting a pointless `& {}`.
1941        assert_fixture_matches("struct_field_flatten_only");
1942    }
1943
1944    #[test]
1945    fn struct_field_flatten_catch_all_map() {
1946        // Serde's catch-all idiom. A string-keyed map is object-shaped, so
1947        // it intersects cleanly.
1948        assert_fixture_matches("struct_field_flatten_catch_all_map");
1949    }
1950
1951    #[test]
1952    fn enum_struct_variant_flatten() {
1953        // Struct variants share the named-field collector, so flatten works
1954        // inside a variant payload too.
1955        assert_fixture_matches("enum_struct_variant_flatten");
1956    }
1957
1958    #[test]
1959    fn struct_field_flatten_peels_smart_pointers() {
1960        // `Box<T>` is transparent to serde, so it flattens like `T`.
1961        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        // The flattened field contributes no key of its own, so rename_all
1976        // applies to the surviving properties only.
1977        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    /// Assert a flattened field of type `field_ty` is rejected, and that the
1994    /// reason mentions `needle`.
1995    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        // Serde makes the whole group absent-or-present; an intersection
2018        // can't say that, and `Partial<T>` would admit any subset.
2019        assert_flatten_rejected("Option<StepMeta>", "absent-or-present");
2020        // The same is true through a smart pointer.
2021        assert_flatten_rejected("Box<Option<StepMeta>>", "absent-or-present");
2022    }
2023
2024    #[test]
2025    fn struct_field_flatten_rejects_non_object_renderings() {
2026        // `X & string` collapses to `never`; `X & unknown` is a silent
2027        // no-op; `X & T[]` describes nothing on the wire. All hard errors.
2028        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        // A skipped field never reaches the wire; a flattened one has no key
2054        // of its own. Together they leave a bare intersection.
2055        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        // If EmitConfig::case_default is set and the container has no
2110        // rename_all, fields get the config-level transform.
2111        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        // The container's explicit rename_all overrides the config-level
2126        // default — closer-scope wins.
2127        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        // user_name is already snake_case, so the container's mode is a no-op
2138        // and we get `user_name`, NOT camelCased `userName`.
2139        assert!(ts.contains("user_name: string"), "ts was: {ts}");
2140        assert!(!ts.contains("userName"), "ts was: {ts}");
2141    }
2142}