Skip to content

fix(rust): stop double-boxing nullable cycle-breaking unions#3040

Merged
schani merged 7 commits into
masterfrom
agent/fix-issue-1112
Jul 21, 2026
Merged

fix(rust): stop double-boxing nullable cycle-breaking unions#3040
schani merged 7 commits into
masterfrom
agent/fix-issue-1112

Conversation

@schani

@schani schani commented Jul 20, 2026

Copy link
Copy Markdown
Member

Bug

The Rust backend generated redundant boxes for a struct field whose type is a nullable union that is also a cycle breaker, e.g. Box<Option<Box<ChiangMaiGoose>>> instead of Option<Box<ChiangMaiGoose>>.

Root cause

In RustRenderer.breakCycle, the field-level call wraps an outer Box around any type marked as a cycle breaker. When the field's type is a nullable union rendered as Option<Enum>, and that union is itself the cycle breaker, the union-rendering path (rustType) already boxes the enum inside the Option (Option<Box<Enum>>). breakCycle then wrapped another Box around the whole thing, producing Box<Option<Box<Enum>>>.

Fix

packages/quicktype-core/src/language/Rust/RustRenderer.ts: in breakCycle, when t is a UnionType that is a cycle breaker, skip the extra outer Box if the union isn't a simple T | null wrapper around a non-cycle-breaking T (i.e. nullableFromUnion(t) === null, the multi-member/enum case) or if the inner nullable type is itself already the cycle breaker — both cases where the necessary box is already emitted by rustType.

Test coverage

  • test/inputs/schema/rust-cycle-breaker-union.schema + .1.json: a new JSON Schema fixture (self-referential object via anyOf with a string alternative and null, forcing a named Rust enum) covering the double-boxing scenario end-to-end via the schema-rust fixture.
  • test/unit/rust-cycle-breaker-boxing.test.ts: unit tests asserting Option<Box<Next>> (not Box<Option<Box<Next>>>) for the nullable case, and that the non-nullable cycle-breaking union case still emits a single Box<Next> (no regression).

Verification

  • npm run build passes.
  • npx vitest run test/unit/rust-cycle-breaker-boxing.test.ts — 2/2 pass.
  • Manual repro with node dist/index.js --lang rust --src-lang schema node.schema on a nullable self-referential union now emits Option<Box<Next>> instead of Box<Option<Box<Next>>>.
  • Full QUICKTEST=true FIXTURE=rust script/test (58 tests × renderer-option variants) — all passed, no regressions.
  • Full QUICKTEST=true FIXTURE=schema-rust script/test (74 tests, including the new rust-cycle-breaker-union.schema) — all passed, no regressions.

Fixes #1112

🤖 Generated with Claude Code

schani and others added 7 commits July 20, 2026 18:19
The Rust renderer's breakCycle wrapped an extra outer Box around a
field whose type is a nullable union that is itself a cycle breaker,
producing Box<Option<Box<T>>> instead of Option<Box<T>>. The union
rendering path already boxes its cycle-breaking member, so the
field-level Box was redundant.

Co-Authored-By: gpt-5.6-sol via pi <noreply@openai.com>
The new rust-cycle-breaker-union.schema (a self-referential union member,
i.e. a union whose member recursively refers back to the enclosing object)
crashes cJSON's multi-source renderer with an internal error during
generation. This is a pre-existing cJSON limitation, unrelated to the Rust
double-boxing fix this schema was added to cover; all other languages
generate valid, round-tripping code. Scope the schema out for cjson.

Co-Authored-By: Claude <noreply@anthropic.com>
KotlinX renders unions as sealed classes without serializer wiring, so
deserialization of polymorphic union members fails at runtime with
"Class discriminator was missing" (a pre-existing, documented KotlinX
limitation shared by ~15 other union schemas already skipped in
KotlinXLanguage.skipSchema). The new rust-cycle-breaker-union.schema
hits the same limitation; scope it out for kotlinx like the others.

Co-Authored-By: Claude <noreply@anthropic.com>
PR #898 (merged after #2357) fixed enum case ordering to preserve JSON
Schema declaration order instead of the previous incidental
alphabetical order. test/unit/cjson-enum-default.test.ts, added by
#2357, still asserted the old alphabetical order
(CONFIG, HEARTBEAT, STATE) and started failing on master once #898
landed. Update the expectation to the correct, now-declaration-order
output (STATE, CONFIG, HEARTBEAT), matching the schema's
enum: ["state", "config", "heartbeat"].

Co-Authored-By: Claude <noreply@anthropic.com>
Elm type aliases cannot be recursive (documented at the top of
ElmLanguage.skipSchema, and already applied to mutually-recursive.schema,
recursive-union-flattening.schema, etc). The generated decoder/encoder
for the new rust-cycle-breaker-union.schema forms a genuinely cyclic
top-level value definition (`next` depends on `nodeClass` depends on
`next`), which elm make rejects with a CYCLIC DEFINITION error since
it isn't wrapped in Jdec.lazy. Scope the schema out for elm like the
other recursive schemas.

Co-Authored-By: Claude <noreply@anthropic.com>
@schani
schani merged commit 021f791 into master Jul 21, 2026
28 checks passed
@schani
schani deleted the agent/fix-issue-1112 branch July 21, 2026 13:13
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.

Rust: Too many boxes

1 participant