From 9185017f9ca23f9ad6f3085732ac3c37fec0100c Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 18:19:49 -0400 Subject: [PATCH 1/6] fix(rust): stop double-boxing nullable cycle-breaking unions (#1112) 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>> instead of Option>. 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 --- .../src/language/Rust/RustRenderer.ts | 7 +++ .../schema/rust-cycle-breaker-union.1.json | 7 +++ .../schema/rust-cycle-breaker-union.schema | 16 +++++++ test/unit/rust-cycle-breaker-boxing.test.ts | 48 +++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 test/inputs/schema/rust-cycle-breaker-union.1.json create mode 100644 test/inputs/schema/rust-cycle-breaker-union.schema create mode 100644 test/unit/rust-cycle-breaker-boxing.test.ts diff --git a/packages/quicktype-core/src/language/Rust/RustRenderer.ts b/packages/quicktype-core/src/language/Rust/RustRenderer.ts index eda0ace698..08777cb5dd 100644 --- a/packages/quicktype-core/src/language/Rust/RustRenderer.ts +++ b/packages/quicktype-core/src/language/Rust/RustRenderer.ts @@ -184,6 +184,13 @@ export class RustRenderer extends ConvenienceRenderer { const rustType = this.rustType(t, withIssues); const isCycleBreaker = this.isCycleBreakerType(t); + if (isCycleBreaker && t instanceof UnionType) { + const nullable = nullableFromUnion(t); + if (nullable === null || this.isCycleBreakerType(nullable)) { + return rustType; + } + } + return isCycleBreaker ? ["Box<", rustType, ">"] : rustType; } diff --git a/test/inputs/schema/rust-cycle-breaker-union.1.json b/test/inputs/schema/rust-cycle-breaker-union.1.json new file mode 100644 index 0000000000..0e44a45e50 --- /dev/null +++ b/test/inputs/schema/rust-cycle-breaker-union.1.json @@ -0,0 +1,7 @@ +{ + "value": "root", + "next": { + "value": "leaf", + "next": "done" + } +} diff --git a/test/inputs/schema/rust-cycle-breaker-union.schema b/test/inputs/schema/rust-cycle-breaker-union.schema new file mode 100644 index 0000000000..f2a709c442 --- /dev/null +++ b/test/inputs/schema/rust-cycle-breaker-union.schema @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Node", + "type": "object", + "properties": { + "value": { "type": "string" }, + "next": { + "anyOf": [ + { "$ref": "#" }, + { "type": "string" }, + { "type": "null" } + ] + } + }, + "required": ["value"] +} diff --git a/test/unit/rust-cycle-breaker-boxing.test.ts b/test/unit/rust-cycle-breaker-boxing.test.ts new file mode 100644 index 0000000000..c738c27697 --- /dev/null +++ b/test/unit/rust-cycle-breaker-boxing.test.ts @@ -0,0 +1,48 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function renderRust(next: object, requireNext = false): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "Node", + schema: JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + title: "Node", + type: "object", + properties: { + value: { type: "string" }, + next, + }, + required: requireNext ? ["value", "next"] : ["value"], + }), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "rust" }); + return result.lines.join("\n"); +} + +const recursiveUnionMembers = [{ $ref: "#" }, { type: "string" }]; + +describe("Rust cycle-breaker boxing", () => { + test("does not box an Option whose union member is already boxed", async () => { + const output = await renderRust({ + anyOf: [...recursiveUnionMembers, { type: "null" }], + }); + + expect(output).toContain("pub next: Option>,"); + expect(output).not.toContain("Box>>"); + }); + + test("keeps a non-nullable cycle-breaking union boxed", async () => { + const output = await renderRust({ anyOf: recursiveUnionMembers }, true); + + expect(output).toContain("pub next: Box,"); + }); +}); From 018f12fd4de49dda68fb462ee806c8b96a1e357b Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 19:37:14 -0400 Subject: [PATCH 2/6] test: add missing fixture cases for rust-cycle-breaker-union.schema (#3040) Co-Authored-By: Claude --- test/inputs/schema/rust-cycle-breaker-union.1.fail.union.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 test/inputs/schema/rust-cycle-breaker-union.1.fail.union.json diff --git a/test/inputs/schema/rust-cycle-breaker-union.1.fail.union.json b/test/inputs/schema/rust-cycle-breaker-union.1.fail.union.json new file mode 100644 index 0000000000..11747fa523 --- /dev/null +++ b/test/inputs/schema/rust-cycle-breaker-union.1.fail.union.json @@ -0,0 +1,4 @@ +{ + "value": "root", + "next": 42 +} From 8661a841ffb787f35efb88e67f40c5aea716bfc0 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:11:02 -0400 Subject: [PATCH 3/6] test: skip rust-cycle-breaker-union.schema for cjson 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 --- test/languages.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 7f80f5e3b1..94cc61788a 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -618,6 +618,11 @@ export const CJSONLanguage: Language = { "direct-union.schema", "optional-any.schema", "recursive-union-flattening.schema", + /* Self-referential union member (a union whose member recursively + * refers back to the enclosing object) is not supported by the + * multi-source renderer; generation aborts. Pre-existing cJSON + * limitation, unrelated to the Rust fixture this schema targets. */ + "rust-cycle-breaker-union.schema", "required-non-properties.schema", /* Class elements with invalid type are not checked (for the current implementation, can be added later, should abord parsing and return NULL) */ ...skipsUntypedUnions, From 0ddad42325f711b5f1f1cc7689380d38f5f30a8c Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:59:50 -0400 Subject: [PATCH 4/6] fix CI: skip rust-cycle-breaker-union.schema for kotlinx (#1112) 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 --- test/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/languages.ts b/test/languages.ts index 94cc61788a..1c22afdbcd 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1603,6 +1603,7 @@ export const KotlinXLanguage: Language = { "mutually-recursive.schema", "prefix-items.schema", "recursive-union-flattening.schema", + "rust-cycle-breaker-union.schema", "tuple.schema", "union-int-double.schema", "union-list.schema", From d5443bda8a09fdd02dfa5a3e4f258a723e752037 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 21:12:33 -0400 Subject: [PATCH 5/6] fix CI: update stale cjson enum-order test expectation 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 --- test/unit/cjson-enum-default.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/cjson-enum-default.test.ts b/test/unit/cjson-enum-default.test.ts index b2c02d60ee..0ecd24cb92 100644 --- a/test/unit/cjson-enum-default.test.ts +++ b/test/unit/cjson-enum-default.test.ts @@ -28,9 +28,9 @@ describe("cJSON enum invalid value", () => { const output = await cJSONOutput(); expect(output).toContain(`enum Subscription { - SUBSCRIPTION_CONFIG = 1, + SUBSCRIPTION_STATE = 1, + SUBSCRIPTION_CONFIG, SUBSCRIPTION_HEARTBEAT, - SUBSCRIPTION_STATE, };`); expect(output).toContain("enum Subscription x = 0;"); }); From d159a825d54de5017dbf25e1105c87eb2edfd884 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 21:19:08 -0400 Subject: [PATCH 6/6] fix CI: skip rust-cycle-breaker-union.schema for elm (#1112) 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 --- test/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/languages.ts b/test/languages.ts index 11391b6ef7..8aa6a3de07 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -892,6 +892,7 @@ export const ElmLanguage: Language = { "vega-lite.schema", // recursion "simple-ref.schema", // recursion "recursive-union-flattening.schema", // recursion + "rust-cycle-breaker-union.schema", // recursion // elm/json's field decoder uses the JS `in` operator, which finds // inherited Object.prototype members, so an absent "constructor" // property decodes to the object's constructor function.