@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,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,110 @@
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
+ result[field.name] = exampleFor(field.type, modelMap, enumMap, visiting, field.name);
45
+ }
46
+
47
+ visiting.delete(model.name);
48
+ return result;
49
+ }
50
+
51
+ export function exampleFor(
52
+ type: TypeRef,
53
+ modelMap: Map<string, Model>,
54
+ enumMap: Map<string, Enum>,
55
+ visiting: Set<string>,
56
+ fieldName: string,
57
+ ): unknown {
58
+ switch (type.kind) {
59
+ case 'primitive':
60
+ switch (type.type) {
61
+ case 'string':
62
+ if (type.format === 'date-time') return '2023-01-01T00:00:00.000Z';
63
+ if (type.format === 'date') return '2023-01-01';
64
+ if (type.format === 'uuid') return '00000000-0000-0000-0000-000000000000';
65
+ if (fieldName === 'id') return 'test_id';
66
+ if (fieldName === 'email') return 'test@example.com';
67
+ return `test_${fieldName}`;
68
+ case 'integer':
69
+ return 0;
70
+ case 'number':
71
+ return 0;
72
+ case 'boolean':
73
+ return false;
74
+ case 'unknown':
75
+ return {};
76
+ }
77
+ return null;
78
+ case 'array':
79
+ return [exampleFor(type.items, modelMap, enumMap, visiting, fieldName)];
80
+ case 'map':
81
+ return {};
82
+ case 'nullable':
83
+ return exampleFor(type.inner, modelMap, enumMap, visiting, fieldName);
84
+ case 'literal':
85
+ return type.value;
86
+ case 'enum': {
87
+ const e = enumMap.get(type.name);
88
+ const v = e?.values?.[0]?.value;
89
+ return v ?? '';
90
+ }
91
+ case 'model': {
92
+ const m = modelMap.get(type.name);
93
+ if (!m) return {};
94
+ return generateModelFixture(m, modelMap, enumMap, visiting);
95
+ }
96
+ case 'union': {
97
+ // Find first model variant; fall back to empty object.
98
+ let result: unknown = null;
99
+ walkTypeRef(type.variants[0]!, {
100
+ primitive: () => {
101
+ result = exampleFor(type.variants[0]!, modelMap, enumMap, visiting, fieldName);
102
+ },
103
+ });
104
+ if (result === null) {
105
+ result = exampleFor(type.variants[0]!, modelMap, enumMap, visiting, fieldName);
106
+ }
107
+ return result;
108
+ }
109
+ }
110
+ }
@@ -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,150 @@
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
+ if (field.description) {
109
+ for (const c of docComment(field.description)) lines.push(` ${c}`);
110
+ }
111
+
112
+ const rename = rustField !== field.name ? field.name : null;
113
+
114
+ let baseType = mapTypeRef(field.type, {
115
+ hint: `${typeName(modelName)}${typeName(field.name)}`,
116
+ registry,
117
+ });
118
+ const isOptional = !field.required || field.type.kind === 'nullable';
119
+ if (isOptional && !baseType.startsWith('Option<')) {
120
+ baseType = makeOptional(baseType);
121
+ }
122
+ // Wrap String / Option<String> in SecretString when the field name implies
123
+ // the value is a credential or token. Wire format is unchanged.
124
+ baseType = applySecretRedaction(baseType, field.name);
125
+
126
+ if (rename) lines.push(` #[serde(rename = "${rename}")]`);
127
+ if (baseType.startsWith('Option<')) {
128
+ lines.push(' #[serde(skip_serializing_if = "Option::is_none", default)]');
129
+ }
130
+ if (field.deprecated) lines.push(' #[deprecated]');
131
+ lines.push(` pub ${rustField}: ${baseType},`);
132
+ return lines.join('\n');
133
+ }
134
+
135
+ function renderModelsBarrel(modules: string[]): string {
136
+ const sorted = [...new Set(modules)].sort();
137
+ const lines: string[] = [];
138
+ for (const m of sorted) lines.push(`pub mod ${m};`);
139
+ lines.push('');
140
+ for (const m of sorted) lines.push(`pub use ${m}::*;`);
141
+ return lines.join('\n') + '\n';
142
+ }
143
+
144
+ function docComment(text: string): string[] {
145
+ return text
146
+ .split('\n')
147
+ .map((l) => l.trim())
148
+ .filter((l) => l.length > 0)
149
+ .map((l) => `/// ${l}`);
150
+ }