Skip to content

fix: preserve Rust untagged-union member payloads - #174

Merged
adamcavendish merged 1 commit into
mainfrom
adamcavendish/rust-union-selection
Nov 11, 2026
Merged

adamcavendish merged 1 commit into
mainfrom
adamcavendish/rust-union-selection

Conversation

@adamcavendish

@adamcavendish adamcavendish commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fix enum-aware untagged-union member selection in all three Rust backends:
reqwest, ureq, and aioduct. Correctly select the declared member and retain its
payload through properties, containers, recursive models, and HTTP responses.

Problem

Serde's derived untagged dispatch tries members whose constrained numeric fields
are still ordinary Rust integers. For a union of revision: enum [1] and
revision: enum [2], {"revision":2,"second_value":"two"} previously selected the
first member and re-encoded as {"revision":2}. Direct member decoding worked;
the union silently discarded the actual member's payload.

Behavior

Policy Result
firstMatch (default) Keep typed variants; choose the first structural match, concrete before fallback.
strict Keep typed variants; reject multiple concrete candidates before decoding.
raw Emit only Raw(serde_json::Value) and preserve known, unmatched, or null JSON.
No typed match Return a deserialization error without adding a public fallback variant.
Selected decoding fails Propagate the original error, including nested strict-union errors.

Matching consumes shared IR facts: required wire keys, scalar finite constraints,
nested arrays/maps, null boundaries, and productive recursive references. Numeric
enum comparisons retain integer precision above 2^53. Recursive checks share
shape/node results within one selection, avoiding exponential subtree scans
without changing candidate order or sharing cached results across decoded values.

Common union derives and per-type overrides accept equivalent short,
crate-qualified, and absolute Serde/utoipa paths without duplicating owned
implementations. Tagged model emission failures preserve the underlying
diagnostic and model context rather than silently omitting the model.

Compatibility and boundaries

  • Default/strict typed variants, payload types, and untagged serialization remain
    unchanged. Deserialization no longer accepts a member whose retained enum or
    structural constraints do not match. Explicit raw mode changes the union API.
  • Structural selection is not full JSON Schema validation. Native Serde decoding
    still applies after selection; ordinary model serialization/validation is not
    redesigned. The shared Rust generator config gains untagged_union_strategy.
  • No synthetic Null or Raw variant is added to typed enums. Explicit nullable
    use sites retain Option<Union>; named root-nullable union representation and
    automatic reference nullability are unchanged.
  • Unknown-field retention, dictionary/additional-property repairs, and
    multi-member allOf redesign remain separate. Use raw mode to retain arbitrary
    union JSON rather than relying on a typed member to preserve unknown fields.
  • Only Rust generator behavior changes. The Python pattern merely ignores the
    added shared-matcher metadata; Java, Kotlin, Go, and TypeScript are unchanged.

@codecov

codecov Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.15891% with 80 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/generators/rust/common/matching.rs 84.25% 54 Missing ⚠️
src/generators/rust/common/emit_models.rs 74.24% 17 Missing ⚠️
src/generators/rust/common/union.rs 95.10% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

@adamcavendish
adamcavendish force-pushed the adamcavendish/rust-union-selection branch from ce2e8e8 to e1e2988 Compare November 11, 2026 07:13
- Match retained scalar enum and structural facts before choosing a Rust union
  member, preventing unconstrained integer fields from swallowing later payloads.
- Share firstMatch, strict, and explicit raw policies across reqwest, ureq, and
  aioduct while retaining typed variants and untagged serialization by default.
- Count authored concrete candidates before opaque fallbacks and deserialize
  the selected member once, retaining nested errors and recursive boundaries.
- Memoize recursive shape/node results during selection to avoid exponential
  subtree scans without changing candidate order or caching across decoded values.
- Keep exact large-integer comparisons, nullable finite constraints, native byte
  arrays, and concrete entries in open recursive dictionaries during selection.
- Emit union declarations and serde/utoipa methods structurally; normalize owned
  derive paths, including absolute names, without duplicate trait implementations.
- Propagate tagged model emission errors with their original model/source context
  instead of silently dropping failed output.
- Retain native model and HTTP consumer regressions, exact renders, and all three
  Rust golden suites without changing other languages or broader model codecs.
@adamcavendish
adamcavendish force-pushed the adamcavendish/rust-union-selection branch from e1e2988 to 07cd3e9 Compare November 11, 2026 10:20
@adamcavendish
adamcavendish merged commit ac392d5 into main Nov 11, 2026
31 checks passed
@adamcavendish
adamcavendish deleted the adamcavendish/rust-union-selection branch November 11, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant