@workos/oagen-emitters 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ import type { TypeRef, UnionType } from '@workos/oagen';
2
+ import { typeName } from './naming.js';
3
+
4
+ /**
5
+ * Lightweight registry that synthesises a named Rust enum for every IR union
6
+ * encountered in field positions. Models reference the synthesised name; the
7
+ * generator emits the enum bodies into a separate module so the crate stays
8
+ * self-contained.
9
+ */
10
+ export class UnionRegistry {
11
+ private byKey = new Map<
12
+ string,
13
+ { name: string; tag?: string; arms: { name: string; type: string; rename?: string }[] }
14
+ >();
15
+ private hintCounts = new Map<string, number>();
16
+
17
+ /** Total number of registered unions (used by callers to skip emit). */
18
+ size(): number {
19
+ return this.byKey.size;
20
+ }
21
+
22
+ /** Drop all collected unions. Call at the start of every emit run. */
23
+ reset(): void {
24
+ this.byKey.clear();
25
+ this.hintCounts.clear();
26
+ }
27
+
28
+ /**
29
+ * Register a union and return the Rust type name to reference. Unions with
30
+ * identical structure (same variants + discriminator) are deduplicated.
31
+ */
32
+ register(union: UnionType, hint: string): string {
33
+ const variants = arms(union);
34
+
35
+ // Apply discriminator mapping (`{spec-value: '#/components/schemas/Foo'}`)
36
+ // as `#[serde(rename = "spec-value")]` on the matching variant. Without
37
+ // this, serde's tagged-union deserialization expects the discriminator
38
+ // value to literally equal the Rust variant name, which it never does
39
+ // when the OAS uses snake/kebab-case spec-side keys.
40
+ const mapping = union.discriminator?.mapping ?? {};
41
+ const inverseMapping = new Map<string, string>();
42
+ for (const [specKey, ref] of Object.entries(mapping)) {
43
+ const schemaName = ref.replace(/^#\/components\/schemas\//, '');
44
+ // Last value wins — matches spec semantics where the final mapping for
45
+ // a schema is canonical.
46
+ inverseMapping.set(typeName(schemaName), specKey);
47
+ }
48
+ const armsWithRename: { name: string; type: string; rename?: string }[] = variants.map((v) => {
49
+ const rename = inverseMapping.get(v.name);
50
+ return rename !== undefined ? { ...v, rename } : v;
51
+ });
52
+
53
+ const key = JSON.stringify({
54
+ variants: armsWithRename.map((v) => ({ type: v.type, rename: v.rename ?? null })),
55
+ discriminator: union.discriminator?.property ?? null,
56
+ mapping: union.discriminator?.mapping ?? null,
57
+ });
58
+ const existing = this.byKey.get(key);
59
+ if (existing) return existing.name;
60
+
61
+ const name = this.uniqueName(`${typeName(hint)}OneOf`);
62
+ this.byKey.set(key, {
63
+ name,
64
+ tag: union.discriminator?.property,
65
+ arms: armsWithRename,
66
+ });
67
+ return name;
68
+ }
69
+
70
+ /**
71
+ * Render every registered union as a Rust source file (including a leading
72
+ * `use serde::...;` line).
73
+ */
74
+ render(): string {
75
+ if (this.byKey.size === 0) return '';
76
+ const blocks: string[] = [];
77
+ blocks.push('#[allow(unused_imports)]');
78
+ blocks.push('use super::*;');
79
+ blocks.push('#[allow(unused_imports)]');
80
+ blocks.push('use crate::enums::*;');
81
+ blocks.push('use serde::{Deserialize, Serialize};');
82
+ blocks.push('');
83
+
84
+ for (const u of this.byKey.values()) {
85
+ blocks.push('#[derive(Debug, Clone, Serialize, Deserialize)]');
86
+ if (u.tag) {
87
+ blocks.push(`#[serde(tag = ${JSON.stringify(u.tag)})]`);
88
+ } else {
89
+ blocks.push('#[serde(untagged)]');
90
+ }
91
+ blocks.push(`pub enum ${u.name} {`);
92
+ for (const a of u.arms) {
93
+ if (a.rename !== undefined) {
94
+ blocks.push(` #[serde(rename = ${JSON.stringify(a.rename)})]`);
95
+ }
96
+ const single = ` ${a.name}(${a.type}),`;
97
+ if (single.length <= 100) {
98
+ blocks.push(single);
99
+ } else {
100
+ // Match rustfmt's break-shape for over-long tuple variants:
101
+ // Variant(
102
+ // LongType,
103
+ // ),
104
+ blocks.push(` ${a.name}(`);
105
+ blocks.push(` ${a.type},`);
106
+ blocks.push(' ),');
107
+ }
108
+ }
109
+ blocks.push('}');
110
+ blocks.push('');
111
+ }
112
+
113
+ return blocks.join('\n').replace(/\n+$/g, '\n');
114
+ }
115
+
116
+ private uniqueName(base: string): string {
117
+ const taken = new Set(Array.from(this.byKey.values()).map((u) => u.name));
118
+ if (!taken.has(base)) return base;
119
+ const n = (this.hintCounts.get(base) ?? 1) + 1;
120
+ this.hintCounts.set(base, n);
121
+ return `${base}${n}`;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Map an IR `TypeRef` to a Rust type expression (e.g., `String`, `Vec<u32>`,
127
+ * `Option<HashMap<String, serde_json::Value>>`).
128
+ *
129
+ * The caller decides whether to wrap the result in `Option<...>` based on the
130
+ * field's `required` flag — `mapTypeRef` itself only emits `Option` for the
131
+ * `nullable` IR variant.
132
+ *
133
+ * When `ctx.registry` is supplied, encountered unions are registered as
134
+ * synthesised Rust enums (using `ctx.hint` to name the generated type); when
135
+ * no registry is provided, unions degrade to `serde_json::Value`.
136
+ */
137
+ export function mapTypeRef(ref: TypeRef, ctx?: { hint?: string; registry?: UnionRegistry }): string {
138
+ switch (ref.kind) {
139
+ case 'primitive':
140
+ return primitiveType(ref.type, ref.format);
141
+ case 'array':
142
+ return `Vec<${mapTypeRef(ref.items, ctx)}>`;
143
+ case 'model':
144
+ return typeName(ref.name);
145
+ case 'enum':
146
+ return typeName(ref.name);
147
+ case 'nullable':
148
+ return `Option<${mapTypeRef(ref.inner, ctx)}>`;
149
+ case 'literal':
150
+ return literalType(ref.value);
151
+ case 'map':
152
+ return `std::collections::HashMap<String, ${mapTypeRef(ref.valueType, ctx)}>`;
153
+ case 'union': {
154
+ const variants = ref.variants;
155
+ const mapped = variants.map((v) => mapTypeRef(v, ctx));
156
+ const unique = Array.from(new Set(mapped));
157
+ if (unique.length === 1) return unique[0]!;
158
+ if (ctx?.registry && ctx.hint) {
159
+ return ctx.registry.register(ref, ctx.hint);
160
+ }
161
+ return 'serde_json::Value';
162
+ }
163
+ }
164
+ }
165
+
166
+ /** Variants for the registry: each gets a Rust variant name + a payload type. */
167
+ function arms(union: UnionType): { name: string; type: string }[] {
168
+ const seen = new Set<string>();
169
+ const out: { name: string; type: string }[] = [];
170
+ for (const v of union.variants) {
171
+ const t = mapTypeRef(v); // No registry — variants must already be named.
172
+ const armName = variantArmName(v, t);
173
+ let unique = armName;
174
+ let n = 1;
175
+ while (seen.has(unique)) {
176
+ n += 1;
177
+ unique = `${armName}${n}`;
178
+ }
179
+ seen.add(unique);
180
+ out.push({ name: unique, type: t });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ function variantArmName(ref: TypeRef, mappedType: string): string {
186
+ if (ref.kind === 'model' || ref.kind === 'enum') return typeName(ref.name);
187
+ // Strip generics for arm naming and PascalCase.
188
+ const base = mappedType.replace(/[<>:,\s]/g, '');
189
+ return typeName(base);
190
+ }
191
+
192
+ /**
193
+ * Wrap a type in `Option<...>` if not already optional. Used for non-required
194
+ * fields where the IR did not produce a `nullable` wrapper.
195
+ */
196
+ export function makeOptional(rustType: string): string {
197
+ if (rustType.startsWith('Option<')) return rustType;
198
+ return `Option<${rustType}>`;
199
+ }
200
+
201
+ function primitiveType(type: 'string' | 'integer' | 'number' | 'boolean' | 'unknown', format?: string): string {
202
+ switch (type) {
203
+ case 'string':
204
+ if (format === 'binary') return 'Vec<u8>';
205
+ return 'String';
206
+ case 'integer':
207
+ if (format === 'int32') return 'i32';
208
+ return 'i64';
209
+ case 'number':
210
+ if (format === 'float') return 'f32';
211
+ return 'f64';
212
+ case 'boolean':
213
+ return 'bool';
214
+ case 'unknown':
215
+ return 'serde_json::Value';
216
+ }
217
+ }
218
+
219
+ function literalType(value: string | number | boolean | null): string {
220
+ if (value === null) return 'serde_json::Value';
221
+ if (typeof value === 'string') return 'String';
222
+ if (typeof value === 'number') return Number.isInteger(value) ? 'i64' : 'f64';
223
+ if (typeof value === 'boolean') return 'bool';
224
+ return 'serde_json::Value';
225
+ }
@@ -60,6 +60,7 @@ describe('public entrypoint (@workos/oagen-emitters)', () => {
60
60
  mod.dotnetEmitter,
61
61
  mod.kotlinEmitter,
62
62
  mod.rubyEmitter,
63
+ mod.rustEmitter,
63
64
  ];
64
65
  for (const emitter of directEmitters) {
65
66
  expect(pluginLanguages).toContain(emitter.language);
@@ -11,7 +11,8 @@ describe('workosEmittersPlugin', () => {
11
11
  expect(languages).toContain('dotnet');
12
12
  expect(languages).toContain('kotlin');
13
13
  expect(languages).toContain('ruby');
14
- expect(languages).toHaveLength(7);
14
+ expect(languages).toContain('rust');
15
+ expect(languages).toHaveLength(8);
15
16
  });
16
17
 
17
18
  it('exports extractors for all supported languages', () => {
@@ -83,8 +83,8 @@ describe('generateResources', () => {
83
83
  // GET method with path param
84
84
  expect(content).toContain('def get_organization(');
85
85
  expect(content).toContain('id: str,');
86
- expect(content).toContain(`f"organizations/{quote(str(id), safe='')}"`);
87
- expect(content).toContain('from urllib.parse import quote');
86
+ expect(content).toContain(`("organizations", str(id))`);
87
+ expect(content).not.toContain('from urllib.parse import quote');
88
88
  expect(content).toContain('model=Organization');
89
89
  // Public request methods (no underscore prefix)
90
90
  expect(content).toContain('self._client.request(');
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateClient } from '../../src/rust/client.js';
5
+ import { generateModels } from '../../src/rust/models.js';
6
+ import { UnionRegistry } from '../../src/rust/type-map.js';
7
+
8
+ function makeCtx(spec: ApiSpec): EmitterContext {
9
+ return { namespace: 'workos', namespacePascal: 'WorkOS', spec };
10
+ }
11
+
12
+ const emptySpec: ApiSpec = {
13
+ name: 'Test',
14
+ version: '1.0.0',
15
+ baseUrl: '',
16
+ services: [],
17
+ models: [],
18
+ enums: [],
19
+ sdk: defaultSdkBehavior(),
20
+ };
21
+
22
+ describe('rust/client', () => {
23
+ it('emits the unions module and an empty resources_api shell when there are no mount targets', () => {
24
+ const files = generateClient(emptySpec, makeCtx(emptySpec), new UnionRegistry());
25
+ expect(files.map((f) => f.path).sort()).toEqual(['src/models/_unions.rs', 'src/resources_api.rs']);
26
+ const unions = files.find((f) => f.path === 'src/models/_unions.rs')!;
27
+ expect(unions.content).toContain('No oneOf-style unions registered');
28
+ const api = files.find((f) => f.path === 'src/resources_api.rs')!;
29
+ expect(api.content).toContain('impl Client {');
30
+ });
31
+
32
+ it('renders unions registered earlier in the emit run', () => {
33
+ const registry = new UnionRegistry();
34
+ const models: Model[] = [
35
+ {
36
+ name: 'Event',
37
+ fields: [
38
+ {
39
+ name: 'payload',
40
+ type: {
41
+ kind: 'union',
42
+ variants: [
43
+ { kind: 'model', name: 'UserCreated' },
44
+ { kind: 'model', name: 'UserDeleted' },
45
+ ],
46
+ discriminator: {
47
+ property: 'event',
48
+ mapping: { 'user.created': 'UserCreated', 'user.deleted': 'UserDeleted' },
49
+ },
50
+ },
51
+ required: true,
52
+ },
53
+ ],
54
+ },
55
+ ];
56
+ generateModels(models, makeCtx(emptySpec), registry);
57
+ const files = generateClient(emptySpec, makeCtx(emptySpec), registry);
58
+ const unions = files.find((f) => f.path === 'src/models/_unions.rs')!;
59
+ expect(unions.content).toContain('#[serde(tag = "event")]');
60
+ expect(unions.content).toContain('pub enum EventPayloadOneOf {');
61
+ });
62
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Enum } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateEnums } from '../../src/rust/enums.js';
5
+
6
+ const emptySpec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: '',
10
+ services: [],
11
+ models: [],
12
+ enums: [],
13
+ sdk: defaultSdkBehavior(),
14
+ };
15
+
16
+ const ctx: EmitterContext = {
17
+ namespace: 'workos',
18
+ namespacePascal: 'WorkOS',
19
+ spec: emptySpec,
20
+ };
21
+
22
+ describe('rust/enums', () => {
23
+ it('emits a non_exhaustive enum with manual Serialize/Deserialize and a fallback variant', () => {
24
+ const enums: Enum[] = [
25
+ {
26
+ name: 'Status',
27
+ values: [
28
+ { name: 'active', value: 'active' },
29
+ { name: 'inactive', value: 'inactive' },
30
+ ],
31
+ },
32
+ ];
33
+ const files = generateEnums(enums, ctx);
34
+ const f = files.find((x) => x.path === 'src/enums/status.rs')!;
35
+ expect(f.content).toContain('pub enum Status {');
36
+ expect(f.content).toContain('Active,');
37
+ expect(f.content).toContain('Inactive,');
38
+ expect(f.content).toContain('#[non_exhaustive]');
39
+ expect(f.content).toContain('Unknown(String),');
40
+ expect(f.content).toContain('impl Serialize for Status');
41
+ expect(f.content).toContain("impl<'de> Deserialize<'de> for Status");
42
+ expect(f.content).toContain('Self::Active => "active"');
43
+ expect(f.content).toContain('Self::Inactive => "inactive"');
44
+ // No derive(Serialize/Deserialize) — they're hand-written.
45
+ expect(f.content).not.toContain('Serialize, Deserialize)]');
46
+ });
47
+
48
+ it('round-trips non-snake-case wire values through canonical strings', () => {
49
+ const enums: Enum[] = [
50
+ {
51
+ name: 'Mode',
52
+ values: [
53
+ { name: 'kebab-case', value: 'kebab-case' },
54
+ { name: 'mixedCase', value: 'mixedCase' },
55
+ ],
56
+ },
57
+ ];
58
+ const files = generateEnums(enums, ctx);
59
+ const f = files.find((x) => x.path === 'src/enums/mode.rs')!;
60
+ // FromStr matches the original wire string; as_str returns it back.
61
+ expect(f.content).toContain('"kebab-case" => Self::KebabCase');
62
+ expect(f.content).toContain('"mixedCase" => Self::MixedCase');
63
+ expect(f.content).toContain('Self::KebabCase => "kebab-case"');
64
+ expect(f.content).toContain('Self::MixedCase => "mixedCase"');
65
+ });
66
+
67
+ it('collapses alias wire values into a single canonical variant', () => {
68
+ const enums: Enum[] = [
69
+ {
70
+ name: 'Trigger',
71
+ values: [
72
+ { name: 'sign-up', value: 'sign-up' },
73
+ { name: 'sign_up', value: 'sign_up' },
74
+ { name: 'sign up', value: 'sign up' },
75
+ ],
76
+ },
77
+ ];
78
+ const files = generateEnums(enums, ctx);
79
+ const f = files.find((x) => x.path === 'src/enums/trigger.rs')!;
80
+ // One Rust variant for all three aliases.
81
+ expect(f.content.match(/^\s+SignUp,$/m)).not.toBeNull();
82
+ // Canonical wire value is the first one seen.
83
+ expect(f.content).toContain('Self::SignUp => "sign-up"');
84
+ // Every alias deserializes into the same variant.
85
+ expect(f.content).toContain('"sign-up" => Self::SignUp');
86
+ expect(f.content).toContain('"sign_up" => Self::SignUp');
87
+ expect(f.content).toContain('"sign up" => Self::SignUp');
88
+ });
89
+
90
+ it('falls back to a non-Unknown name when the spec defines an Unknown variant', () => {
91
+ const enums: Enum[] = [
92
+ {
93
+ name: 'State',
94
+ values: [
95
+ { name: 'unknown', value: 'unknown' },
96
+ { name: 'ready', value: 'ready' },
97
+ ],
98
+ },
99
+ ];
100
+ const files = generateEnums(enums, ctx);
101
+ const f = files.find((x) => x.path === 'src/enums/state.rs')!;
102
+ expect(f.content).toContain('Unknown,');
103
+ expect(f.content).toContain('Unrecognized(String),');
104
+ });
105
+
106
+ it('skips empty enums', () => {
107
+ const enums: Enum[] = [{ name: 'Empty', values: [] }];
108
+ const files = generateEnums(enums, ctx);
109
+ expect(files.find((f) => f.path === 'src/enums/empty.rs')).toBeUndefined();
110
+ });
111
+
112
+ it('always emits a barrel even when no enums', () => {
113
+ const files = generateEnums([], ctx);
114
+ expect(files).toHaveLength(1);
115
+ expect(files[0]!.path).toBe('src/enums/mod.rs');
116
+ });
117
+ });
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { ApiSpec, Enum, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { exampleFromSpec, generateFixtures, generateModelFixture } from '../../src/rust/fixtures.js';
5
+
6
+ function spec(models: Model[], enums: Enum[] = []): ApiSpec {
7
+ return {
8
+ name: 'Test',
9
+ version: '1.0.0',
10
+ baseUrl: '',
11
+ services: [],
12
+ models,
13
+ enums,
14
+ sdk: defaultSdkBehavior(),
15
+ };
16
+ }
17
+
18
+ describe('rust/fixtures', () => {
19
+ it('prefers a spec `example` over the generated placeholder for a primitive field', () => {
20
+ const models: Model[] = [
21
+ {
22
+ name: 'Event',
23
+ fields: [
24
+ {
25
+ name: 'id',
26
+ type: { kind: 'primitive', type: 'string' },
27
+ required: true,
28
+ example: 'event_01XXXX',
29
+ },
30
+ {
31
+ name: 'created_at',
32
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
33
+ required: true,
34
+ example: '2026-02-02T16:35:39.317Z',
35
+ },
36
+ ],
37
+ },
38
+ ];
39
+ const files = generateFixtures(spec(models));
40
+ const file = files.find((f) => f.path === 'tests/fixtures/event.json')!;
41
+ expect(file).toBeDefined();
42
+ const parsed = JSON.parse(file.content);
43
+ expect(parsed.id).toBe('event_01XXXX');
44
+ expect(parsed.created_at).toBe('2026-02-02T16:35:39.317Z');
45
+ });
46
+
47
+ it('falls back to the placeholder when the example shape does not match the type', () => {
48
+ const models: Model[] = [
49
+ {
50
+ name: 'Wrong',
51
+ fields: [
52
+ // Type is integer but example is a string — must fall back.
53
+ {
54
+ name: 'count',
55
+ type: { kind: 'primitive', type: 'integer' },
56
+ required: true,
57
+ example: 'not-a-number',
58
+ },
59
+ ],
60
+ },
61
+ ];
62
+ const files = generateFixtures(spec(models));
63
+ const file = files.find((f) => f.path === 'tests/fixtures/wrong.json')!;
64
+ const parsed = JSON.parse(file.content);
65
+ expect(parsed.count).toBe(0); // placeholder fallback
66
+ });
67
+
68
+ it('uses an example array of strings for an array<string> field', () => {
69
+ const models: Model[] = [
70
+ {
71
+ name: 'Org',
72
+ fields: [
73
+ {
74
+ name: 'domains',
75
+ type: {
76
+ kind: 'array',
77
+ items: { kind: 'primitive', type: 'string' },
78
+ },
79
+ required: true,
80
+ example: ['example.com', 'foo.com'],
81
+ },
82
+ ],
83
+ },
84
+ ];
85
+ const files = generateFixtures(spec(models));
86
+ const file = files.find((f) => f.path === 'tests/fixtures/org.json')!;
87
+ const parsed = JSON.parse(file.content);
88
+ expect(parsed.domains).toEqual(['example.com', 'foo.com']);
89
+ });
90
+
91
+ it('skips a model-shaped example to avoid mis-shaped nested structs', () => {
92
+ const models: Model[] = [
93
+ {
94
+ name: 'Outer',
95
+ fields: [
96
+ {
97
+ name: 'actor',
98
+ type: { kind: 'model', name: 'Actor' },
99
+ required: true,
100
+ // Provided as a free-form example; we should NOT use it verbatim.
101
+ example: { not_a_real_field: 'whoops' },
102
+ },
103
+ ],
104
+ },
105
+ {
106
+ name: 'Actor',
107
+ fields: [
108
+ {
109
+ name: 'id',
110
+ type: { kind: 'primitive', type: 'string' },
111
+ required: true,
112
+ example: 'user_TF4C5938',
113
+ },
114
+ {
115
+ name: 'type',
116
+ type: { kind: 'primitive', type: 'string' },
117
+ required: true,
118
+ example: 'user',
119
+ },
120
+ ],
121
+ },
122
+ ];
123
+ const files = generateFixtures(spec(models));
124
+ const file = files.find((f) => f.path === 'tests/fixtures/outer.json')!;
125
+ const parsed = JSON.parse(file.content);
126
+ // The nested model is regenerated from its own fields' examples, not from
127
+ // the parent's free-form example blob.
128
+ expect(parsed.actor).toEqual({ id: 'user_TF4C5938', type: 'user' });
129
+ });
130
+
131
+ it('uses an enum example only when it matches a known enum value', () => {
132
+ const enums: Enum[] = [
133
+ {
134
+ name: 'Status',
135
+ values: [
136
+ { name: 'Active', value: 'active' },
137
+ { name: 'Pending', value: 'pending' },
138
+ ],
139
+ },
140
+ ];
141
+ const models: Model[] = [
142
+ {
143
+ name: 'GoodEx',
144
+ fields: [
145
+ {
146
+ name: 'status',
147
+ type: { kind: 'enum', name: 'Status' },
148
+ required: true,
149
+ example: 'pending',
150
+ },
151
+ ],
152
+ },
153
+ {
154
+ name: 'BadEx',
155
+ fields: [
156
+ {
157
+ name: 'status',
158
+ type: { kind: 'enum', name: 'Status' },
159
+ required: true,
160
+ example: 'something_unknown',
161
+ },
162
+ ],
163
+ },
164
+ ];
165
+ const files = generateFixtures(spec(models, enums));
166
+ const good = JSON.parse(files.find((f) => f.path === 'tests/fixtures/good_ex.json')!.content);
167
+ const bad = JSON.parse(files.find((f) => f.path === 'tests/fixtures/bad_ex.json')!.content);
168
+ expect(good.status).toBe('pending'); // valid example wins
169
+ expect(bad.status).toBe('active'); // unknown example → first enum value
170
+ });
171
+
172
+ it('treats null examples as unusable so required fields keep a value', () => {
173
+ const models: Model[] = [
174
+ {
175
+ name: 'Nullish',
176
+ fields: [
177
+ {
178
+ name: 'name',
179
+ type: { kind: 'primitive', type: 'string' },
180
+ required: true,
181
+ example: null,
182
+ },
183
+ ],
184
+ },
185
+ ];
186
+ const files = generateFixtures(spec(models));
187
+ const parsed = JSON.parse(files.find((f) => f.path === 'tests/fixtures/nullish.json')!.content);
188
+ expect(parsed.name).toBe('test_name');
189
+ });
190
+
191
+ it('exampleFromSpec exposes the shape-checking helper for reuse', () => {
192
+ const enums = new Map<string, Enum>();
193
+ // Primitives are unwrapped through nullable.
194
+ expect(exampleFromSpec('hello', { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } }, enums)).toBe(
195
+ 'hello',
196
+ );
197
+ // Integer floats are rejected (they would corrupt typed deserialisation).
198
+ expect(exampleFromSpec(1.5, { kind: 'primitive', type: 'integer' }, enums)).toBeUndefined();
199
+ // Empty arrays fall back so the placeholder can emit a one-element array.
200
+ expect(exampleFromSpec([], { kind: 'array', items: { kind: 'primitive', type: 'string' } }, enums)).toBeUndefined();
201
+ });
202
+
203
+ it('threads required-only field selection through generateModelFixture', () => {
204
+ const models: Model[] = [
205
+ {
206
+ name: 'Mixed',
207
+ fields: [
208
+ {
209
+ name: 'kept',
210
+ type: { kind: 'primitive', type: 'string' },
211
+ required: true,
212
+ example: 'real',
213
+ },
214
+ {
215
+ name: 'dropped',
216
+ type: { kind: 'primitive', type: 'string' },
217
+ required: false,
218
+ example: 'ignored',
219
+ },
220
+ ],
221
+ },
222
+ ];
223
+ const modelMap = new Map(models.map((m) => [m.name, m]));
224
+ const fixture = generateModelFixture(models[0]!, modelMap, new Map(), new Set());
225
+ expect(fixture).toEqual({ kept: 'real' });
226
+ });
227
+ });