@workos/oagen-emitters 0.11.0 → 0.12.0

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,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,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { buildOperationsMap } from '../../src/rust/manifest.js';
5
+
6
+ const spec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: '',
10
+ services: [
11
+ {
12
+ name: 'Organizations',
13
+ operations: [
14
+ {
15
+ name: 'createOrganization',
16
+ httpMethod: 'post',
17
+ path: '/organizations',
18
+ pathParams: [],
19
+ queryParams: [],
20
+ headerParams: [],
21
+ response: { kind: 'primitive', type: 'unknown' },
22
+ errors: [],
23
+ injectIdempotencyKey: false,
24
+ },
25
+ {
26
+ name: 'getOrganization',
27
+ httpMethod: 'get',
28
+ path: '/organizations/{id}',
29
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
30
+ queryParams: [],
31
+ headerParams: [],
32
+ response: { kind: 'primitive', type: 'unknown' },
33
+ errors: [],
34
+ injectIdempotencyKey: false,
35
+ },
36
+ ],
37
+ },
38
+ ],
39
+ models: [],
40
+ enums: [],
41
+ sdk: defaultSdkBehavior(),
42
+ };
43
+
44
+ const ctx: EmitterContext = {
45
+ namespace: 'workos',
46
+ namespacePascal: 'WorkOS',
47
+ spec,
48
+ resolvedOperations: spec.services.flatMap((service) =>
49
+ service.operations.map((operation) => ({
50
+ service,
51
+ operation,
52
+ methodName: operation.name,
53
+ mountOn: service.name,
54
+ defaults: {},
55
+ inferFromClient: [],
56
+ urlBuilder: false,
57
+ })),
58
+ ),
59
+ };
60
+
61
+ describe('rust/manifest', () => {
62
+ it('maps each HTTP operation to an SDK method + service accessor', () => {
63
+ const map = buildOperationsMap(spec, ctx);
64
+ expect(map['POST /organizations']).toEqual({
65
+ sdkMethod: 'create_organization',
66
+ service: 'organizations',
67
+ });
68
+ expect(map['GET /organizations/{id}']).toEqual({
69
+ sdkMethod: 'get_organization',
70
+ service: 'organizations',
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateModels } from '../../src/rust/models.js';
5
+ import { generateClient } from '../../src/rust/client.js';
6
+ import { UnionRegistry } from '../../src/rust/type-map.js';
7
+
8
+ const emptySpec: ApiSpec = {
9
+ name: 'Test',
10
+ version: '1.0.0',
11
+ baseUrl: '',
12
+ services: [],
13
+ models: [],
14
+ enums: [],
15
+ sdk: defaultSdkBehavior(),
16
+ };
17
+
18
+ const ctx: EmitterContext = {
19
+ namespace: 'workos',
20
+ namespacePascal: 'WorkOS',
21
+ spec: emptySpec,
22
+ };
23
+
24
+ describe('rust/models', () => {
25
+ it('emits only an empty barrel when no models', () => {
26
+ const files = generateModels([], ctx, new UnionRegistry());
27
+ expect(files).toHaveLength(1);
28
+ expect(files[0]!.path).toBe('src/models/mod.rs');
29
+ });
30
+
31
+ it('generates a struct with required and optional fields', () => {
32
+ const models: Model[] = [
33
+ {
34
+ name: 'Organization',
35
+ fields: [
36
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
37
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
38
+ {
39
+ name: 'metadata',
40
+ type: { kind: 'map', valueType: { kind: 'primitive', type: 'string' } },
41
+ required: false,
42
+ },
43
+ ],
44
+ },
45
+ ];
46
+ const files = generateModels(models, ctx, new UnionRegistry());
47
+ expect(files.length).toBeGreaterThanOrEqual(2); // model + barrel
48
+ const orgFile = files.find((f) => f.path === 'src/models/organization.rs')!;
49
+ expect(orgFile).toBeDefined();
50
+ const content = orgFile.content;
51
+ expect(content).toContain('use serde::{Deserialize, Serialize};');
52
+ expect(content).toContain('pub struct Organization {');
53
+ expect(content).toContain('pub id: String,');
54
+ expect(content).toContain('pub name: String,');
55
+ expect(content).toContain('pub metadata: Option<std::collections::HashMap<String, String>>,');
56
+ expect(content).toContain('#[derive(Debug, Clone, Serialize, Deserialize)]');
57
+ });
58
+
59
+ it('renames struct fields when serde wire name differs', () => {
60
+ const models: Model[] = [
61
+ {
62
+ name: 'User',
63
+ fields: [{ name: 'userId', type: { kind: 'primitive', type: 'string' }, required: true }],
64
+ },
65
+ ];
66
+ const files = generateModels(models, ctx, new UnionRegistry());
67
+ const userFile = files.find((f) => f.path === 'src/models/user.rs')!;
68
+ expect(userFile.content).toContain('#[serde(rename = "userId")]');
69
+ expect(userFile.content).toContain('pub user_id: String,');
70
+ });
71
+
72
+ it('skips serializing None for optional fields', () => {
73
+ const models: Model[] = [
74
+ {
75
+ name: 'Maybe',
76
+ fields: [{ name: 'value', type: { kind: 'primitive', type: 'string' }, required: false }],
77
+ },
78
+ ];
79
+ const files = generateModels(models, ctx, new UnionRegistry());
80
+ const f = files.find((x) => x.path === 'src/models/maybe.rs')!;
81
+ expect(f.content).toContain('#[serde(skip_serializing_if = "Option::is_none", default)]');
82
+ expect(f.content).toContain('pub value: Option<String>,');
83
+ });
84
+
85
+ it('synthesises a _unions module when a model has an inline union field', () => {
86
+ const models: Model[] = [
87
+ {
88
+ name: 'Event',
89
+ fields: [
90
+ {
91
+ name: 'payload',
92
+ type: {
93
+ kind: 'union',
94
+ variants: [
95
+ { kind: 'model', name: 'UserCreated' },
96
+ { kind: 'model', name: 'UserDeleted' },
97
+ ],
98
+ discriminator: {
99
+ property: 'event',
100
+ mapping: { 'user.created': 'UserCreated', 'user.deleted': 'UserDeleted' },
101
+ },
102
+ },
103
+ required: true,
104
+ },
105
+ ],
106
+ },
107
+ ];
108
+ const registry = new UnionRegistry();
109
+ const files = generateModels(models, ctx, registry);
110
+ const event = files.find((f) => f.path === 'src/models/event.rs')!;
111
+ expect(event.content).toContain('pub payload: EventPayloadOneOf,');
112
+ const barrel = files.find((f) => f.path === 'src/models/mod.rs')!;
113
+ expect(barrel.content).toContain('pub mod _unions;');
114
+ // The _unions.rs file is rendered by generateClient (the final structural
115
+ // pass) so resource-side body unions can join the same registry.
116
+ const clientFiles = generateClient(emptySpec, ctx, registry);
117
+ const unions = clientFiles.find((f) => f.path === 'src/models/_unions.rs')!;
118
+ expect(unions.content).toContain('#[serde(tag = "event")]');
119
+ expect(unions.content).toContain('pub enum EventPayloadOneOf {');
120
+ });
121
+
122
+ it('emits a barrel re-exporting each module', () => {
123
+ const models: Model[] = [
124
+ {
125
+ name: 'Alpha',
126
+ fields: [{ name: 'x', type: { kind: 'primitive', type: 'string' }, required: true }],
127
+ },
128
+ {
129
+ name: 'Beta',
130
+ fields: [{ name: 'y', type: { kind: 'primitive', type: 'string' }, required: true }],
131
+ },
132
+ ];
133
+ const files = generateModels(models, ctx, new UnionRegistry());
134
+ const barrel = files.find((f) => f.path === 'src/models/mod.rs')!;
135
+ expect(barrel.content).toContain('pub mod alpha;');
136
+ expect(barrel.content).toContain('pub mod beta;');
137
+ expect(barrel.content).toContain('pub use alpha::*;');
138
+ });
139
+ });
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateResources } from '../../src/rust/resources.js';
5
+ import { UnionRegistry } from '../../src/rust/type-map.js';
6
+
7
+ const emptySpec: ApiSpec = {
8
+ name: 'Test',
9
+ version: '1.0.0',
10
+ baseUrl: '',
11
+ services: [],
12
+ models: [],
13
+ enums: [],
14
+ sdk: defaultSdkBehavior(),
15
+ };
16
+
17
+ const ctx: EmitterContext = {
18
+ namespace: 'workos',
19
+ namespacePascal: 'WorkOS',
20
+ spec: emptySpec,
21
+ };
22
+
23
+ /**
24
+ * Build a resolved-operations-aware context. The Rust emitter groups
25
+ * operations by `mountOn`, which the resolver populates; tests that want to
26
+ * exercise the per-resource emitter need to seed those entries themselves.
27
+ */
28
+ function ctxWithResolved(services: Service[]): EmitterContext {
29
+ return {
30
+ ...ctx,
31
+ spec: { ...emptySpec, services },
32
+ resolvedOperations: services.flatMap((service) =>
33
+ service.operations.map((operation) => ({
34
+ service,
35
+ operation,
36
+ methodName: operation.name,
37
+ mountOn: service.name,
38
+ defaults: {},
39
+ inferFromClient: [],
40
+ urlBuilder: false,
41
+ })),
42
+ ),
43
+ };
44
+ }
45
+
46
+ describe('rust/resources', () => {
47
+ it('skips services with no operations', () => {
48
+ const services: Service[] = [{ name: 'Empty', operations: [] }];
49
+ const files = generateResources(services, ctxWithResolved(services), new UnionRegistry());
50
+ expect(files.find((f) => f.path.startsWith('src/resources/empty'))).toBeUndefined();
51
+ });
52
+
53
+ it('emits a resource struct with async methods', () => {
54
+ const services: Service[] = [
55
+ {
56
+ name: 'Organizations',
57
+ operations: [
58
+ {
59
+ name: 'createOrganization',
60
+ httpMethod: 'post',
61
+ path: '/organizations',
62
+ pathParams: [],
63
+ queryParams: [],
64
+ headerParams: [],
65
+ response: { kind: 'model', name: 'Organization' },
66
+ errors: [],
67
+ injectIdempotencyKey: false,
68
+ },
69
+ ],
70
+ },
71
+ ];
72
+ const files = generateResources(services, ctxWithResolved(services), new UnionRegistry());
73
+ const f = files.find((x) => x.path === 'src/resources/organizations.rs')!;
74
+ expect(f.content).toContain("pub struct OrganizationsApi<'a> {");
75
+ expect(f.content).toContain("pub(crate) client: &'a Client,");
76
+ expect(f.content).toContain('pub async fn create_organization(');
77
+ expect(f.content).toContain(' -> Result<Organization, Error>');
78
+ expect(f.content).toContain('http::Method::POST');
79
+ });
80
+
81
+ it('treats request body as required by default and passes Some(&body)', () => {
82
+ const services: Service[] = [
83
+ {
84
+ name: 'Issues',
85
+ operations: [
86
+ {
87
+ name: 'createIssue',
88
+ httpMethod: 'post',
89
+ path: '/issues',
90
+ pathParams: [],
91
+ queryParams: [],
92
+ headerParams: [],
93
+ requestBody: { kind: 'model', name: 'CreateIssueRequest' },
94
+ response: { kind: 'model', name: 'Issue' },
95
+ errors: [],
96
+ injectIdempotencyKey: false,
97
+ },
98
+ ],
99
+ },
100
+ ];
101
+ const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
102
+ (x) => x.path === 'src/resources/issues.rs',
103
+ )!;
104
+ expect(f.content).toContain('pub struct CreateIssueParams {');
105
+ expect(f.content).toContain('pub body: CreateIssueRequest,');
106
+ expect(f.content).not.toContain('pub body: Option<CreateIssueRequest>');
107
+ expect(f.content).toContain('Some(&params.body)');
108
+ // Required body forbids the Default derive.
109
+ expect(f.content).toContain('#[derive(Debug, Clone, Serialize)]');
110
+ });
111
+
112
+ it('treats nullable request body as optional and passes params.body.as_ref()', () => {
113
+ const services: Service[] = [
114
+ {
115
+ name: 'Issues',
116
+ operations: [
117
+ {
118
+ name: 'updateIssue',
119
+ httpMethod: 'patch',
120
+ path: '/issues/{id}',
121
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
122
+ queryParams: [],
123
+ headerParams: [],
124
+ requestBody: {
125
+ kind: 'nullable',
126
+ inner: { kind: 'model', name: 'UpdateIssueRequest' },
127
+ },
128
+ response: { kind: 'model', name: 'Issue' },
129
+ errors: [],
130
+ injectIdempotencyKey: false,
131
+ },
132
+ ],
133
+ },
134
+ ];
135
+ const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
136
+ (x) => x.path === 'src/resources/issues.rs',
137
+ )!;
138
+ expect(f.content).toContain('pub body: Option<UpdateIssueRequest>,');
139
+ expect(f.content).toContain('params.body.as_ref()');
140
+ expect(f.content).toContain('#[derive(Debug, Clone, Default, Serialize)]');
141
+ });
142
+
143
+ it('renders multi-line operation descriptions as multi-line doc comments', () => {
144
+ const services: Service[] = [
145
+ {
146
+ name: 'Users',
147
+ operations: [
148
+ {
149
+ name: 'listUsers',
150
+ description: 'List all users.\n\nSupports cursor pagination via `after`.',
151
+ httpMethod: 'get',
152
+ path: '/users',
153
+ pathParams: [],
154
+ queryParams: [],
155
+ headerParams: [],
156
+ response: { kind: 'model', name: 'UsersList' },
157
+ errors: [],
158
+ injectIdempotencyKey: false,
159
+ },
160
+ ],
161
+ },
162
+ ];
163
+ const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
164
+ (x) => x.path === 'src/resources/users.rs',
165
+ )!;
166
+ expect(f.content).toContain(' /// List all users.');
167
+ expect(f.content).toContain(' ///');
168
+ expect(f.content).toContain(' /// Supports cursor pagination via `after`.');
169
+ });
170
+
171
+ it('reads inferFromClient body fields from the runtime client', () => {
172
+ const services: Service[] = [
173
+ {
174
+ name: 'UserManagement',
175
+ operations: [
176
+ {
177
+ name: 'authenticate',
178
+ httpMethod: 'post',
179
+ path: '/user_management/authenticate',
180
+ pathParams: [],
181
+ queryParams: [],
182
+ headerParams: [],
183
+ response: { kind: 'model', name: 'AuthenticateResponse' },
184
+ errors: [],
185
+ injectIdempotencyKey: false,
186
+ },
187
+ ],
188
+ },
189
+ ];
190
+ const baseCtx = ctxWithResolved(services);
191
+ const ctxWithWrapper: EmitterContext = {
192
+ ...baseCtx,
193
+ resolvedOperations: baseCtx.resolvedOperations!.map((r) => ({
194
+ ...r,
195
+ wrappers: [
196
+ {
197
+ name: 'authenticate_with_code',
198
+ targetVariant: 'AuthorizationCodeSessionAuthenticateRequest',
199
+ defaults: { grant_type: 'authorization_code' },
200
+ inferFromClient: ['client_id', 'client_secret'],
201
+ exposedParams: ['code'],
202
+ optionalParams: [],
203
+ responseModelName: null,
204
+ },
205
+ ],
206
+ })),
207
+ };
208
+ const f = generateResources(services, ctxWithWrapper, new UnionRegistry()).find(
209
+ (x) => x.path === 'src/resources/user_management.rs',
210
+ )!;
211
+ // Inferred fields read from the runtime client, not empty literals.
212
+ expect(f.content).toContain('"client_id": self.client.client_id()');
213
+ expect(f.content).toContain('"client_secret": self.client.api_key()');
214
+ expect(f.content).not.toContain('"client_id": "",');
215
+ expect(f.content).not.toContain('"client_secret": "",');
216
+ // Defaults are still emitted as literal JSON values.
217
+ expect(f.content).toContain('"grant_type": "authorization_code"');
218
+ });
219
+
220
+ it('interpolates path parameters via format!', () => {
221
+ const services: Service[] = [
222
+ {
223
+ name: 'Users',
224
+ operations: [
225
+ {
226
+ name: 'getUser',
227
+ httpMethod: 'get',
228
+ path: '/users/{id}',
229
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
230
+ queryParams: [],
231
+ headerParams: [],
232
+ response: { kind: 'model', name: 'User' },
233
+ errors: [],
234
+ injectIdempotencyKey: false,
235
+ },
236
+ ],
237
+ },
238
+ ];
239
+ const files = generateResources(services, ctxWithResolved(services), new UnionRegistry());
240
+ const f = files.find((x) => x.path === 'src/resources/users.rs')!;
241
+ expect(f.content).toContain('let id = crate::client::path_segment(id);');
242
+ expect(f.content).toContain('let path = format!("/users/{id}");');
243
+ expect(f.content).toContain('pub async fn get_user(&self, id: &str');
244
+ });
245
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { TypeRef, UnionType } from '@workos/oagen';
3
+ import { mapTypeRef, UnionRegistry } from '../../src/rust/type-map.js';
4
+
5
+ describe('rust/type-map', () => {
6
+ it('falls back to serde_json::Value for non-discriminated unions when no registry', () => {
7
+ const u: UnionType = {
8
+ kind: 'union',
9
+ variants: [
10
+ { kind: 'model', name: 'Foo' },
11
+ { kind: 'model', name: 'Bar' },
12
+ ],
13
+ };
14
+ expect(mapTypeRef(u)).toBe('serde_json::Value');
15
+ });
16
+
17
+ it('synthesises a named enum when a registry is provided', () => {
18
+ const reg = new UnionRegistry();
19
+ const u: TypeRef = {
20
+ kind: 'union',
21
+ variants: [
22
+ { kind: 'model', name: 'Foo' },
23
+ { kind: 'model', name: 'Bar' },
24
+ ],
25
+ };
26
+ const name = mapTypeRef(u, { hint: 'WidgetThing', registry: reg });
27
+ expect(name).toBe('WidgetThingOneOf');
28
+ const body = reg.render();
29
+ expect(body).toContain('#[serde(untagged)]');
30
+ expect(body).toContain('pub enum WidgetThingOneOf {');
31
+ expect(body).toContain('Foo(Foo),');
32
+ expect(body).toContain('Bar(Bar),');
33
+ });
34
+
35
+ it('emits a tagged enum when the union has a discriminator', () => {
36
+ const reg = new UnionRegistry();
37
+ const u: TypeRef = {
38
+ kind: 'union',
39
+ variants: [
40
+ { kind: 'model', name: 'CatEvent' },
41
+ { kind: 'model', name: 'DogEvent' },
42
+ ],
43
+ discriminator: {
44
+ property: 'event',
45
+ mapping: { 'cat.created': 'CatEvent', 'dog.created': 'DogEvent' },
46
+ },
47
+ };
48
+ mapTypeRef(u, { hint: 'EventPayload', registry: reg });
49
+ const body = reg.render();
50
+ expect(body).toContain('#[serde(tag = "event")]');
51
+ expect(body).toContain('pub enum EventPayloadOneOf {');
52
+ });
53
+
54
+ it('deduplicates structurally identical unions to a single type', () => {
55
+ const reg = new UnionRegistry();
56
+ const u: TypeRef = {
57
+ kind: 'union',
58
+ variants: [
59
+ { kind: 'model', name: 'A' },
60
+ { kind: 'model', name: 'B' },
61
+ ],
62
+ };
63
+ const a = mapTypeRef(u, { hint: 'First', registry: reg });
64
+ const b = mapTypeRef(u, { hint: 'Second', registry: reg });
65
+ expect(a).toBe(b);
66
+ expect(reg.size()).toBe(1);
67
+ });
68
+
69
+ it('collapses single-variant unions to the inner type', () => {
70
+ const reg = new UnionRegistry();
71
+ const u: TypeRef = {
72
+ kind: 'union',
73
+ variants: [{ kind: 'primitive', type: 'string' }],
74
+ };
75
+ expect(mapTypeRef(u, { hint: 'X', registry: reg })).toBe('String');
76
+ expect(reg.size()).toBe(0);
77
+ });
78
+
79
+ it('maps int32 format to i32 and float to f32', () => {
80
+ expect(mapTypeRef({ kind: 'primitive', type: 'integer', format: 'int32' })).toBe('i32');
81
+ expect(mapTypeRef({ kind: 'primitive', type: 'number', format: 'float' })).toBe('f32');
82
+ });
83
+ });