@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,201 @@
1
+ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { typeName, moduleName, variantName } from './naming.js';
3
+
4
+ /**
5
+ * Generate one Rust source file per enum under `src/enums/`, plus a
6
+ * `src/enums/mod.rs` barrel.
7
+ *
8
+ * Each enum is emitted as a string-backed, forward-compatible Rust enum:
9
+ * - `#[non_exhaustive]` so callers can't write exhaustive matches that
10
+ * break when WorkOS adds a new value server-side.
11
+ * - A fallback variant (`Unknown(String)` by default, or `Unrecognized` /
12
+ * `Other` / `OagenUnknown` if the spec already defines a variant by that
13
+ * name) captures any wire value the SDK doesn't yet recognize, preserving
14
+ * the original string instead of failing deserialization.
15
+ * - Manual `Serialize`/`Deserialize` map between the canonical wire string
16
+ * and the Rust variant; alias wire values deserialize into the canonical
17
+ * variant and re-serialize as the canonical wire string.
18
+ * - `Display`, `FromStr`, and `AsRef<str>` are implemented for ergonomics.
19
+ */
20
+ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFile[] {
21
+ const files: GeneratedFile[] = [];
22
+ const seen = new Set<string>();
23
+ const moduleNames: string[] = [];
24
+
25
+ for (const e of enums) {
26
+ if (!e.values || e.values.length === 0) continue;
27
+ const mod = moduleName(e.name);
28
+ if (seen.has(mod)) continue;
29
+ seen.add(mod);
30
+ moduleNames.push(mod);
31
+
32
+ files.push({
33
+ path: `src/enums/${mod}.rs`,
34
+ content: renderEnum(e),
35
+ overwriteExisting: true,
36
+ });
37
+ }
38
+
39
+ files.push({
40
+ path: 'src/enums/mod.rs',
41
+ content: renderEnumsBarrel(moduleNames),
42
+ overwriteExisting: true,
43
+ });
44
+
45
+ return files;
46
+ }
47
+
48
+ function renderEnum(e: Enum): string {
49
+ const lines: string[] = [];
50
+ const tname = typeName(e.name);
51
+
52
+ // Collapse wire values that map to the same Rust variant. The first wire
53
+ // value for a variant is the canonical one (used for serialization); the
54
+ // remaining ones are aliases (accepted on deserialization).
55
+ const order: string[] = [];
56
+ const wireByVariant = new Map<string, string[]>();
57
+ const descByVariant = new Map<string, string | undefined>();
58
+ const deprecatedByVariant = new Map<string, boolean>();
59
+ for (const v of e.values) {
60
+ const variant = variantName(v.value);
61
+ if (!wireByVariant.has(variant)) {
62
+ wireByVariant.set(variant, []);
63
+ order.push(variant);
64
+ descByVariant.set(variant, v.description);
65
+ deprecatedByVariant.set(variant, !!v.deprecated);
66
+ }
67
+ wireByVariant.get(variant)!.push(String(v.value));
68
+ }
69
+
70
+ // Pick a fallback-variant name that doesn't collide with an existing one.
71
+ // Prefer `Unknown` for ergonomics; fall back to less-likely names for the
72
+ // (rare) enums whose spec already defines `Unknown`/`Unrecognized`/`Other`.
73
+ const taken = new Set(order);
74
+ const FB = ['Unknown', 'Unrecognized', 'Other', 'OagenUnknown'].find((c) => !taken.has(c)) ?? 'OagenUnknown';
75
+
76
+ lines.push('use serde::{Deserialize, Serialize};');
77
+ lines.push('use std::fmt;');
78
+ lines.push('use std::str::FromStr;');
79
+ lines.push('');
80
+
81
+ lines.push('#[derive(Debug, Clone, PartialEq, Eq, Hash)]');
82
+ lines.push('#[non_exhaustive]');
83
+ lines.push(`pub enum ${tname} {`);
84
+ for (const variant of order) {
85
+ const desc = descByVariant.get(variant);
86
+ if (desc) {
87
+ for (const c of docComment(desc)) lines.push(` ${c}`);
88
+ }
89
+ if (deprecatedByVariant.get(variant)) lines.push(' #[allow(deprecated)]');
90
+ lines.push(` ${variant},`);
91
+ }
92
+ lines.push(' /// Wire value not recognized by this SDK version. The original');
93
+ lines.push(' /// string is preserved verbatim. WorkOS may add new enum values');
94
+ lines.push(' /// server-side; matching on this variant lets callers handle');
95
+ lines.push(' /// forward-compatible values without panicking.');
96
+ lines.push(` ${FB}(String),`);
97
+ lines.push('}');
98
+ lines.push('');
99
+
100
+ // as_str(): canonical wire value for known variants, inner string for fallback.
101
+ lines.push(`impl ${tname} {`);
102
+ lines.push(` /// Canonical wire string for this value. For [\`Self::${FB}\`] returns the`);
103
+ lines.push(' /// original wire value as received from the API.');
104
+ lines.push(' #[allow(deprecated)]');
105
+ lines.push(' pub fn as_str(&self) -> &str {');
106
+ lines.push(' match self {');
107
+ for (const variant of order) {
108
+ const canonical = wireByVariant.get(variant)![0]!;
109
+ lines.push(` Self::${variant} => ${JSON.stringify(canonical)},`);
110
+ }
111
+ lines.push(` Self::${FB}(s) => s.as_str(),`);
112
+ lines.push(' }');
113
+ lines.push(' }');
114
+ lines.push('}');
115
+ lines.push('');
116
+
117
+ // Display via as_str().
118
+ lines.push(`impl fmt::Display for ${tname} {`);
119
+ lines.push(" fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {");
120
+ lines.push(' f.write_str(self.as_str())');
121
+ lines.push(' }');
122
+ lines.push('}');
123
+ lines.push('');
124
+
125
+ // AsRef<str>.
126
+ lines.push(`impl AsRef<str> for ${tname} {`);
127
+ lines.push(' fn as_ref(&self) -> &str {');
128
+ lines.push(' self.as_str()');
129
+ lines.push(' }');
130
+ lines.push('}');
131
+ lines.push('');
132
+
133
+ // FromStr — infallible (fallback variant captures anything else).
134
+ lines.push(`impl FromStr for ${tname} {`);
135
+ lines.push(' type Err = std::convert::Infallible;');
136
+ lines.push(' #[allow(deprecated)]');
137
+ lines.push(' fn from_str(s: &str) -> Result<Self, Self::Err> {');
138
+ lines.push(' Ok(match s {');
139
+ for (const variant of order) {
140
+ const wires = wireByVariant.get(variant)!;
141
+ for (const w of wires) {
142
+ lines.push(` ${JSON.stringify(w)} => Self::${variant},`);
143
+ }
144
+ }
145
+ lines.push(` other => Self::${FB}(other.to_string()),`);
146
+ lines.push(' })');
147
+ lines.push(' }');
148
+ lines.push('}');
149
+ lines.push('');
150
+
151
+ // From<String>/From<&str>.
152
+ lines.push(`impl From<String> for ${tname} {`);
153
+ lines.push(' fn from(s: String) -> Self {');
154
+ lines.push(' // Reuse the original `String` allocation in the fallback branch.');
155
+ lines.push(' match Self::from_str(&s) {');
156
+ lines.push(` Ok(Self::${FB}(_)) => Self::${FB}(s),`);
157
+ lines.push(' Ok(other) => other,');
158
+ lines.push(' }');
159
+ lines.push(' }');
160
+ lines.push('}');
161
+ lines.push('');
162
+ lines.push(`impl From<&str> for ${tname} {`);
163
+ lines.push(' fn from(s: &str) -> Self {');
164
+ lines.push(` Self::from_str(s).unwrap_or_else(|_| Self::${FB}(s.to_string()))`);
165
+ lines.push(' }');
166
+ lines.push('}');
167
+ lines.push('');
168
+
169
+ // Manual Serialize / Deserialize.
170
+ lines.push(`impl Serialize for ${tname} {`);
171
+ lines.push(' fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {');
172
+ lines.push(' serializer.serialize_str(self.as_str())');
173
+ lines.push(' }');
174
+ lines.push('}');
175
+ lines.push('');
176
+ lines.push(`impl<'de> Deserialize<'de> for ${tname} {`);
177
+ lines.push(" fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {");
178
+ lines.push(' let s = String::deserialize(deserializer)?;');
179
+ lines.push(' Ok(Self::from(s))');
180
+ lines.push(' }');
181
+ lines.push('}');
182
+
183
+ return lines.join('\n') + '\n';
184
+ }
185
+
186
+ function renderEnumsBarrel(modules: string[]): string {
187
+ const sorted = [...new Set(modules)].sort();
188
+ const lines: string[] = [];
189
+ for (const m of sorted) lines.push(`pub mod ${m};`);
190
+ lines.push('');
191
+ for (const m of sorted) lines.push(`pub use ${m}::*;`);
192
+ return lines.join('\n') + '\n';
193
+ }
194
+
195
+ function docComment(text: string): string[] {
196
+ return text
197
+ .split('\n')
198
+ .map((l) => l.trim())
199
+ .filter((l) => l.length > 0)
200
+ .map((l) => `/// ${l}`);
201
+ }
@@ -0,0 +1,196 @@
1
+ import type { ApiSpec, GeneratedFile, Model, Enum, TypeRef } from '@workos/oagen';
2
+ import { walkTypeRef } from '@workos/oagen';
3
+ import { moduleName } from './naming.js';
4
+
5
+ /**
6
+ * Generate JSON test fixture files under `tests/fixtures/`. The Rust tests
7
+ * pull these in via `include_str!` so no I/O is required at test time.
8
+ */
9
+ export function generateFixtures(spec: ApiSpec): GeneratedFile[] {
10
+ const files: GeneratedFile[] = [];
11
+ const modelMap = new Map(spec.models.map((m) => [m.name, m]));
12
+ const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
13
+ const seen = new Set<string>();
14
+
15
+ for (const model of spec.models) {
16
+ if (seen.has(model.name)) continue;
17
+ if (model.fields.length === 0) continue;
18
+ seen.add(model.name);
19
+
20
+ const fixture = generateModelFixture(model, modelMap, enumMap, new Set());
21
+ const path = `tests/fixtures/${moduleName(model.name)}.json`;
22
+ files.push({
23
+ path,
24
+ content: JSON.stringify(fixture, null, 2) + '\n',
25
+ headerPlacement: 'skip',
26
+ });
27
+ }
28
+
29
+ return files;
30
+ }
31
+
32
+ export function generateModelFixture(
33
+ model: Model,
34
+ modelMap: Map<string, Model>,
35
+ enumMap: Map<string, Enum>,
36
+ visiting: Set<string>,
37
+ ): Record<string, unknown> {
38
+ const result: Record<string, unknown> = {};
39
+ if (visiting.has(model.name)) return result; // Break recursion.
40
+ visiting.add(model.name);
41
+
42
+ for (const field of model.fields) {
43
+ if (!field.required) continue;
44
+ // Prefer the spec `example` value when it is shape-compatible with the
45
+ // declared type. Falls back to the placeholder generator when no example
46
+ // is provided or when the example would not deserialize cleanly.
47
+ const fromExample = exampleFromSpec(field.example, field.type, enumMap);
48
+ result[field.name] =
49
+ fromExample !== undefined ? fromExample : exampleFor(field.type, modelMap, enumMap, visiting, field.name);
50
+ }
51
+
52
+ visiting.delete(model.name);
53
+ return result;
54
+ }
55
+
56
+ export function exampleFor(
57
+ type: TypeRef,
58
+ modelMap: Map<string, Model>,
59
+ enumMap: Map<string, Enum>,
60
+ visiting: Set<string>,
61
+ fieldName: string,
62
+ ): unknown {
63
+ switch (type.kind) {
64
+ case 'primitive':
65
+ switch (type.type) {
66
+ case 'string':
67
+ if (type.format === 'date-time') return '2023-01-01T00:00:00.000Z';
68
+ if (type.format === 'date') return '2023-01-01';
69
+ if (type.format === 'uuid') return '00000000-0000-0000-0000-000000000000';
70
+ if (fieldName === 'id') return 'test_id';
71
+ if (fieldName === 'email') return 'test@example.com';
72
+ return `test_${fieldName}`;
73
+ case 'integer':
74
+ return 0;
75
+ case 'number':
76
+ return 0;
77
+ case 'boolean':
78
+ return false;
79
+ case 'unknown':
80
+ return {};
81
+ }
82
+ return null;
83
+ case 'array':
84
+ return [exampleFor(type.items, modelMap, enumMap, visiting, fieldName)];
85
+ case 'map':
86
+ return {};
87
+ case 'nullable':
88
+ return exampleFor(type.inner, modelMap, enumMap, visiting, fieldName);
89
+ case 'literal':
90
+ return type.value;
91
+ case 'enum': {
92
+ const e = enumMap.get(type.name);
93
+ const v = e?.values?.[0]?.value;
94
+ return v ?? '';
95
+ }
96
+ case 'model': {
97
+ const m = modelMap.get(type.name);
98
+ if (!m) return {};
99
+ return generateModelFixture(m, modelMap, enumMap, visiting);
100
+ }
101
+ case 'union': {
102
+ // Find first model variant; fall back to empty object.
103
+ let result: unknown = null;
104
+ walkTypeRef(type.variants[0]!, {
105
+ primitive: () => {
106
+ result = exampleFor(type.variants[0]!, modelMap, enumMap, visiting, fieldName);
107
+ },
108
+ });
109
+ if (result === null) {
110
+ result = exampleFor(type.variants[0]!, modelMap, enumMap, visiting, fieldName);
111
+ }
112
+ return result;
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Resolve a spec-provided `example` against a TypeRef and return the value to
119
+ * embed in the fixture, or `undefined` when the example cannot be used safely.
120
+ *
121
+ * "Safely" means the value would round-trip through serde to the generated
122
+ * Rust type. We deliberately only accept primitives, enum string/number
123
+ * values, and homogenous arrays of those; nested object examples (which the
124
+ * spec sometimes supplies as illustrative metadata blobs) are skipped because
125
+ * they rarely match the strict struct shape Rust expects.
126
+ */
127
+ export function exampleFromSpec(example: unknown, type: TypeRef, enumMap: Map<string, Enum>): unknown {
128
+ if (example === undefined) return undefined;
129
+ // Spec authors sometimes use `null` as a sentinel; let placeholder gen
130
+ // handle nullable types so we don't emit `null` for required fields.
131
+ if (example === null) return undefined;
132
+ return matchExampleToType(example, type, enumMap);
133
+ }
134
+
135
+ function matchExampleToType(value: unknown, type: TypeRef, enumMap: Map<string, Enum>): unknown {
136
+ switch (type.kind) {
137
+ case 'primitive':
138
+ return matchPrimitive(value, type.type);
139
+ case 'literal':
140
+ return value === type.value ? value : undefined;
141
+ case 'enum': {
142
+ const e = enumMap.get(type.name);
143
+ if (!e) return undefined;
144
+ const ok = e.values.some((v) => v.value === value);
145
+ return ok ? value : undefined;
146
+ }
147
+ case 'array': {
148
+ if (!Array.isArray(value)) return undefined;
149
+ const out: unknown[] = [];
150
+ for (const item of value) {
151
+ const matched = matchExampleToType(item, type.items, enumMap);
152
+ if (matched === undefined) return undefined;
153
+ out.push(matched);
154
+ }
155
+ // Empty arrays are valid but unhelpful in fixtures — fall back so the
156
+ // placeholder generator can produce a one-element example.
157
+ if (out.length === 0) return undefined;
158
+ return out;
159
+ }
160
+ case 'nullable':
161
+ return matchExampleToType(value, type.inner, enumMap);
162
+ case 'map':
163
+ // Map examples are usually free-form metadata blobs that match
164
+ // `HashMap<String, _>`; only accept plain objects with string-keyed values.
165
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
166
+ return value;
167
+ case 'union': {
168
+ for (const variant of type.variants) {
169
+ const matched = matchExampleToType(value, variant, enumMap);
170
+ if (matched !== undefined) return matched;
171
+ }
172
+ return undefined;
173
+ }
174
+ case 'model':
175
+ // Model-shaped examples are too risky to copy verbatim: they rarely
176
+ // supply every required field and may use wire names that don't align
177
+ // with the generated struct. Let the recursive generator handle them.
178
+ return undefined;
179
+ }
180
+ }
181
+
182
+ function matchPrimitive(value: unknown, primitive: 'string' | 'integer' | 'number' | 'boolean' | 'unknown'): unknown {
183
+ switch (primitive) {
184
+ case 'string':
185
+ return typeof value === 'string' ? value : undefined;
186
+ case 'integer':
187
+ return typeof value === 'number' && Number.isInteger(value) ? value : undefined;
188
+ case 'number':
189
+ return typeof value === 'number' ? value : undefined;
190
+ case 'boolean':
191
+ return typeof value === 'boolean' ? value : undefined;
192
+ case 'unknown':
193
+ // `unknown` deserialises to `serde_json::Value`, so any JSON value works.
194
+ return value;
195
+ }
196
+ }
@@ -0,0 +1,95 @@
1
+ import type {
2
+ Emitter,
3
+ EmitterContext,
4
+ FormatCommand,
5
+ GeneratedFile,
6
+ ApiSpec,
7
+ Model,
8
+ Enum,
9
+ Service,
10
+ } from '@workos/oagen';
11
+
12
+ import { generateModels } from './models.js';
13
+ import { generateEnums } from './enums.js';
14
+ import { generateResources } from './resources.js';
15
+ import { generateClient } from './client.js';
16
+ import { generateTests } from './tests.js';
17
+ import { buildOperationsMap } from './manifest.js';
18
+ import { UnionRegistry } from './type-map.js';
19
+
20
+ /**
21
+ * Shared per-emit registry that collects synthesised oneOf-style unions
22
+ * encountered in *any* call (model fields, request bodies, etc.). Rendered
23
+ * once into `src/models/_unions.rs` during the final structural pass so
24
+ * downstream files can reference the synthesised type names uniformly.
25
+ */
26
+ const unionRegistry = new UnionRegistry();
27
+
28
+ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
29
+ for (const f of files) {
30
+ if (f.content && !f.content.endsWith('\n')) {
31
+ f.content += '\n';
32
+ }
33
+ }
34
+ return files;
35
+ }
36
+
37
+ export const rustEmitter: Emitter = {
38
+ language: 'rust',
39
+
40
+ generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
41
+ unionRegistry.reset();
42
+ return ensureTrailingNewlines(generateModels(models, ctx, unionRegistry));
43
+ },
44
+
45
+ generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
46
+ return ensureTrailingNewlines(generateEnums(enums, ctx));
47
+ },
48
+
49
+ generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
50
+ return ensureTrailingNewlines(generateResources(services, ctx, unionRegistry));
51
+ },
52
+
53
+ generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
54
+ return ensureTrailingNewlines(generateClient(spec, ctx, unionRegistry));
55
+ },
56
+
57
+ generateErrors(): GeneratedFile[] {
58
+ // Hand-maintained in the target SDK — `src/error.rs`, `src/client.rs`,
59
+ // `src/lib.rs`, `src/pagination.rs`, and `Cargo.toml` all carry
60
+ // `@oagen-ignore-file` and are not regenerated.
61
+ return [];
62
+ },
63
+
64
+ generateTypeSignatures(): GeneratedFile[] {
65
+ return [];
66
+ },
67
+
68
+ generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
69
+ return ensureTrailingNewlines(generateTests(spec, ctx));
70
+ },
71
+
72
+ buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
73
+ return buildOperationsMap(spec, ctx);
74
+ },
75
+
76
+ fileHeader(): string {
77
+ return '// Code generated by oagen. DO NOT EDIT.';
78
+ },
79
+
80
+ formatCommand(_targetDir: string): FormatCommand | null {
81
+ // oagen appends every generated file path (mixing .rs, Cargo.toml, .json
82
+ // fixtures) to the format command. `rustfmt` errors on non-`.rs` paths,
83
+ // so wrap it in a bash filter that only forwards `.rs` files. Same
84
+ // shape as the Go emitter's gofmt wrapper.
85
+ return {
86
+ cmd: 'bash',
87
+ args: [
88
+ '-c',
89
+ 'RS_FILES=$(printf "%s\\n" "$@" | grep "\\.rs$"); [ -n "$RS_FILES" ] && echo "$RS_FILES" | xargs rustfmt --edition 2024 --quiet',
90
+ '--',
91
+ ],
92
+ batchSize: 999999,
93
+ };
94
+ },
95
+ };
@@ -0,0 +1,31 @@
1
+ import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
2
+ import { methodName, moduleName } from './naming.js';
3
+ import { groupByMount } from '../shared/resolved-ops.js';
4
+
5
+ /**
6
+ * Build the operation→SDK-method map written into `.oagen-manifest.json`,
7
+ * which the smoke runner consults to dispatch HTTP operations to SDK calls.
8
+ *
9
+ * Keys are `METHOD /path`; values point at the mount-target accessor on
10
+ * `Client` and the resolved snake_case method name. Split operations register
11
+ * one entry per wrapper (the wrapper name takes precedence over the raw op
12
+ * name since the raw method does not exist in the generated SDK).
13
+ */
14
+ export function buildOperationsMap(_spec: ApiSpec, ctx: EmitterContext): OperationsMap {
15
+ const map: OperationsMap = {};
16
+
17
+ for (const [mountName, group] of groupByMount(ctx)) {
18
+ const accessor = moduleName(mountName);
19
+ for (const r of group.resolvedOps) {
20
+ const httpKey = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}`;
21
+ if ((r.wrappers?.length ?? 0) > 0) {
22
+ const first = r.wrappers![0]!;
23
+ map[httpKey] = { sdkMethod: methodName(first.name), service: accessor };
24
+ } else {
25
+ map[httpKey] = { sdkMethod: methodName(r.methodName), service: accessor };
26
+ }
27
+ }
28
+ }
29
+
30
+ return map;
31
+ }
@@ -0,0 +1,165 @@
1
+ import type { Model, EmitterContext, GeneratedFile, Field } from '@workos/oagen';
2
+ import { typeName, fieldName, moduleName } from './naming.js';
3
+ import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
4
+ import { applySecretRedaction } from './secret.js';
5
+
6
+ const HEADER_PLACEHOLDER = ''; // engine prepends fileHeader()
7
+
8
+ const UNIONS_MODULE = '_unions';
9
+
10
+ /**
11
+ * Generate one Rust source file per model under `src/models/`, plus a
12
+ * `src/models/mod.rs` barrel that re-exports each module. Inline IR unions
13
+ * encountered in field positions are synthesised into a single
14
+ * `src/models/_unions.rs` module so the resulting Rust types are concrete.
15
+ */
16
+ export function generateModels(models: Model[], ctx: EmitterContext, registry: UnionRegistry): GeneratedFile[] {
17
+ const files: GeneratedFile[] = [];
18
+ const moduleNames: string[] = [];
19
+ const seen = new Set<string>();
20
+
21
+ for (const model of models) {
22
+ // Empty-field, non-discriminator models still need to be emitted as an
23
+ // empty struct so request bodies that reference them (e.g. an empty
24
+ // `CreateApplicationSecretDto`) compile.
25
+ const mod = moduleName(model.name);
26
+ if (seen.has(mod)) continue;
27
+ seen.add(mod);
28
+ moduleNames.push(mod);
29
+
30
+ const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
31
+ const path = hintPath ?? `src/models/${mod}.rs`;
32
+ files.push({ path, content: renderModel(model, registry), overwriteExisting: true });
33
+ }
34
+
35
+ // Always include the unions module in the barrel so downstream stages
36
+ // (resources, etc.) that register additional unions don't need to mutate
37
+ // the barrel after the fact. The actual `_unions.rs` file is rendered in
38
+ // generateClient once every stage has finished registering.
39
+ moduleNames.push(UNIONS_MODULE);
40
+
41
+ files.push({
42
+ path: 'src/models/mod.rs',
43
+ content: renderModelsBarrel(moduleNames),
44
+ overwriteExisting: true,
45
+ });
46
+
47
+ return files;
48
+ }
49
+
50
+ function renderModel(model: Model, registry: UnionRegistry): string {
51
+ const lines: string[] = [];
52
+ lines.push(HEADER_PLACEHOLDER);
53
+ // Match rustfmt's canonical grouping: keyword-rooted paths (`super`,
54
+ // `crate`) sort before external-crate paths (`serde`). Pre-emit in that
55
+ // order so `cargo fmt --check` does not reshuffle the file.
56
+ lines.push('#[allow(unused_imports)]');
57
+ lines.push('use super::*;');
58
+ lines.push('#[allow(unused_imports)]');
59
+ lines.push('use crate::enums::*;');
60
+ lines.push('use serde::{Deserialize, Serialize};');
61
+ lines.push('');
62
+
63
+ if (model.description) lines.push(...docComment(model.description));
64
+
65
+ lines.push('#[derive(Debug, Clone, Serialize, Deserialize)]');
66
+
67
+ const resolvedNames = resolveFieldNames(model.fields);
68
+ const fieldLines = model.fields.map((f, i) => renderField(f, resolvedNames[i]!, model.name, registry));
69
+
70
+ // rustfmt collapses zero-field structs to `pub struct Foo {}` on a single
71
+ // line. Match that shape so `cargo fmt --check` passes.
72
+ if (fieldLines.length === 0) {
73
+ lines.push(`pub struct ${typeName(model.name)} {}`);
74
+ } else {
75
+ lines.push(`pub struct ${typeName(model.name)} {`);
76
+ lines.push(...fieldLines);
77
+ lines.push('}');
78
+ }
79
+ return lines.filter((l) => l !== HEADER_PLACEHOLDER).join('\n') + '\n';
80
+ }
81
+
82
+ /**
83
+ * Resolve unique Rust identifiers for struct fields. Multiple wire names can
84
+ * collide after `fieldName()` snake-cases them (e.g. `integration_type` and
85
+ * `integrationType` both become `integration_type`). Subsequent collisions get
86
+ * a numeric suffix so the struct compiles; serde `rename` preserves the
87
+ * original wire name in every case.
88
+ */
89
+ function resolveFieldNames(fields: Field[]): string[] {
90
+ const used = new Set<string>();
91
+ const out: string[] = [];
92
+ for (const f of fields) {
93
+ const base = fieldName(f.name);
94
+ let candidate = base;
95
+ let suffix = 2;
96
+ while (used.has(candidate)) {
97
+ candidate = `${base}_${suffix}`;
98
+ suffix++;
99
+ }
100
+ used.add(candidate);
101
+ out.push(candidate);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ function renderField(field: Field, rustField: string, modelName: string, registry: UnionRegistry): string {
107
+ const lines: string[] = [];
108
+ const hasDescription = !!field.description;
109
+ if (hasDescription) {
110
+ for (const c of docComment(field.description!)) lines.push(` ${c}`);
111
+ }
112
+ if (field.default != null) {
113
+ if (hasDescription) lines.push(' ///');
114
+ lines.push(` /// Defaults to \`${formatDefault(field.default)}\`.`);
115
+ }
116
+
117
+ const rename = rustField !== field.name ? field.name : null;
118
+
119
+ let baseType = mapTypeRef(field.type, {
120
+ hint: `${typeName(modelName)}${typeName(field.name)}`,
121
+ registry,
122
+ });
123
+ const isOptional = !field.required || field.type.kind === 'nullable';
124
+ if (isOptional && !baseType.startsWith('Option<')) {
125
+ baseType = makeOptional(baseType);
126
+ }
127
+ // Wrap String / Option<String> in SecretString when the field name implies
128
+ // the value is a credential or token. Wire format is unchanged.
129
+ baseType = applySecretRedaction(baseType, field.name);
130
+
131
+ if (rename) lines.push(` #[serde(rename = "${rename}")]`);
132
+ if (baseType.startsWith('Option<')) {
133
+ lines.push(' #[serde(skip_serializing_if = "Option::is_none", default)]');
134
+ }
135
+ if (field.deprecated) lines.push(' #[deprecated]');
136
+ lines.push(` pub ${rustField}: ${baseType},`);
137
+ return lines.join('\n');
138
+ }
139
+
140
+ function renderModelsBarrel(modules: string[]): string {
141
+ const sorted = [...new Set(modules)].sort();
142
+ const lines: string[] = [];
143
+ for (const m of sorted) lines.push(`pub mod ${m};`);
144
+ lines.push('');
145
+ for (const m of sorted) lines.push(`pub use ${m}::*;`);
146
+ return lines.join('\n') + '\n';
147
+ }
148
+
149
+ function docComment(text: string): string[] {
150
+ return text
151
+ .split('\n')
152
+ .map((l) => l.trim())
153
+ .filter((l) => l.length > 0)
154
+ .map((l) => `/// ${l}`);
155
+ }
156
+
157
+ /**
158
+ * Render a spec-level default value for inclusion in a doc comment. Strings
159
+ * render bare (e.g. `desc`) so they nest naturally inside the surrounding
160
+ * backticks; numbers/booleans use JSON encoding.
161
+ */
162
+ function formatDefault(value: unknown): string {
163
+ if (typeof value === 'string') return value;
164
+ return JSON.stringify(value);
165
+ }