diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f88884a..3f8370a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,14 @@ +{"id":"openapi-generator-fbn","title":"[Q2.8] REQUIRED_DEPS.toml + stderr advisory for typed-scalar crates","description":"When TypeMapper (Q2.0) produces a type that requires an external crate (chrono, uuid, url, bytes, base64, validator, email_address), record it in TypeMapper's used-features tracker. After generation completes, write \u003coutput_dir\u003e/REQUIRED_DEPS.toml containing copy-pasteable [dependencies] lines for every crate that was actually referenced; print the same summary to stderr at end of run; expose GenerationResult.required_deps: Vec\u003cDepRequirement\u003e for library consumers. This keeps the generator's contract small (it only produces .rs files) while making 'what crates do I need?' explicit.\n\n## Context\nFiles: src/type_mapping.rs (Q2.0 introduces UsedFeatures), src/generator.rs:579 (write_files), src/cli.rs. Evidence: today no Cargo.toml is emitted; src/test_helpers.rs:312 only writes one for compile-gate tests. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] TypeMapper.used_features() returns the set of optional crates referenced.\n- [ ] REQUIRED_DEPS.toml written next to generated code with [dependencies] lines including correct version + features.\n- [ ] Same summary printed to stderr at end of run.\n- [ ] GenerationResult.required_deps exposed.\n- [ ] When no optional crates are used, REQUIRED_DEPS.toml is NOT written (no clutter).","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:36:49Z","created_by":"James Lal","updated_at":"2026-05-09T15:42:52Z","started_at":"2026-05-09T14:51:56Z","closed_at":"2026-05-09T15:42:52Z","close_reason":"REQUIRED_DEPS.toml + stderr advisory shipped. TypeFeature::dep_requirement() returns canonical (crate, version, features) for each optional crate the TypeMapper used. GenerationResult.required_deps populated from analysis.used_type_features. write_files emits \u003coutput_dir\u003e/REQUIRED_DEPS.toml with copy-pasteable [dependencies] block when non-empty (skipped silently when empty). CLI 'generate' subcommand prints the same summary to stderr, ending with the file path so users can find it. Verified end-to-end against anthropic spec (chrono + base64 surfaced). All 5 dep-advisory tests pass; full integration suite passes; spec-compile gate: 54/54 pass.","dependencies":[{"issue_id":"openapi-generator-fbn","depends_on_id":"openapi-generator-quq","type":"blocks","created_at":"2026-05-08T23:37:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-fbn","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-j6n","title":"[Q2.7] Untagged enum for oneOf of primitives (default on)","description":"When oneOf or anyOf consists entirely of primitive types (string/integer/number/boolean), today's analysis falls back to serde_json::Value, which loses type info and forces users to do their own dispatch. Generate an untagged enum with one variant per primitive type instead. Common in real APIs for ID fields that can be string-or-int. E.g. oneOf: [{type: string}, {type: integer}] should become an enum Foo with variants String(String) and Int(i64) under serde untagged.\n\n## Context\nFiles: src/analysis.rs:3284 (analyze_anyof_union) and the oneOf path. Evidence: today these branches call analyze_anyof_union which produces SchemaType::Primitive { rust_type: serde_json::Value } when no discriminator and no shared schema name. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] oneOf/anyOf where every variant is a primitive becomes an untagged enum with one variant per type.\n- [ ] Variant names: String/Int/Float/Bool (collision-free; if same primitive appears twice, append index).\n- [ ] [generator.types.shape] primitive_unions = false reverts to current serde_json::Value.\n- [ ] Round-trip test: deserialize one example per variant, serialize back, byte-equal.\n- [ ] All 49 specs still compile.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:36:39Z","created_by":"James Lal","updated_at":"2026-05-09T17:21:59Z","started_at":"2026-05-09T16:25:05Z","closed_at":"2026-05-09T17:21:59Z","close_reason":"Q2.7 actually surfaced as harmonizing the anyOf primitive-union path with the cleaner oneOf path. oneOf already produced #[serde(untagged)] enum X { String(String), Integer(i64) }; anyOf inserted a per-variant type alias and referenced the alias in the variant. Now both produce the same clean shape. Toggle [generator.types.shape] primitive_unions = false reverts to the pre-Q2.7 alias shape (not serde_json::Value as the original bead description implied — that was stale). Default true. 6 new tests in tests/primitive_unions_test.rs + 5 snapshot updates. spec-compile gate: 54/54 pass.","dependencies":[{"issue_id":"openapi-generator-j6n","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-4mu","title":"[Q2.6] Honor x-enum-varnames and x-enum-descriptions vendor extensions (default on)","description":"Common vendor extension to specify Rust-friendly variant names and descriptions for string enums. When a schema's x-enum-varnames length matches its enum values length, use those as variant identifiers (rename via #[serde(rename = \"\u003coriginal\u003e\")]). When x-enum-descriptions is present, attach each entry as a doc comment on the corresponding variant. Falls back to current heuristic naming when extensions absent or lengths mismatch.\n\n## Context\nFiles: src/analysis.rs (StringEnum analysis around line 1152), src/generator.rs (generate_string_enum). Evidence: 0 occurrences of x-enum-varnames/x-enum-descriptions in src/ today. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] x-enum-varnames overrides default variant Rust naming when length matches values.\n- [ ] Each variant emits #[serde(rename = \"\u003coriginal-value\u003e\")] so wire format is preserved.\n- [ ] x-enum-descriptions emitted as /// doc comments on each variant.\n- [ ] Length mismatch: log a warning, fall back to heuristic naming.\n- [ ] [generator.types.enums] x_enum_varnames / x_enum_descriptions toggles each independently (default true).\n- [ ] All 49 specs still compile.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:36:08Z","created_by":"James Lal","updated_at":"2026-05-09T18:31:02Z","started_at":"2026-05-09T18:22:38Z","closed_at":"2026-05-09T18:31:02Z","close_reason":"Q2.4: constraint annotations surface as /// Constraint: doc comments by default. No client-side validation (validator_crate mode dropped per user feedback — OpenAPI constraints belong on wire contract). Q2.6: x-enum-varnames + x-enum-descriptions vendor extensions honored via SchemaAnalysis.enum_extensions side-channel, with length-mismatch dropping. Both default on, opt-out per toggle. 14 new tests. Spec-compile gate verification pending (running).","dependencies":[{"issue_id":"openapi-generator-4mu","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-d8y","title":"[Q2.4] Constraint annotations as doc comments (default on, validator opt-in)","description":"SchemaDetails (src/openapi.rs:174) parses minimum/maximum/min_length/max_length/pattern/multiple_of/uniqueItems but no codegen consumes them. With 13k+ uniqueItems and 4k+ min/max occurrences in real specs, dropping all of this is a real loss. Add [generator.types.constraints] mode = \"doc\" by default — surfaces constraints as /// Constraint: ... doc comments on fields, no deps. mode = \"off\" preserves current silence.\n\n**No client-side validation** (deferred per user feedback). The generator does not emit `#[validate(...)]` attributes or pull in the validator crate. OpenAPI constraints belong on the wire contract; the server is the source of truth. Doc comments give callers visibility without the client SDK duplicating server logic and going brittle when rules drift.\n\n## Context\nFiles: src/openapi.rs:174 (SchemaDetails), src/generator.rs (field emission), src/config.rs. Evidence: SchemaDetails has constraint fields parsed but they're never read anywhere in src/. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] mode = \"doc\" emits a /// Constraint: ... line on each field with at least one constraint.\n- [ ] mode = \"off\" produces no constraint output (current behavior).\n- [ ] Patterns containing /// or */ are escaped safely in doc comments.\n- [ ] All 49 specs still compile under default (mode = \"doc\").","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:54Z","created_by":"James Lal","updated_at":"2026-05-09T18:31:02Z","started_at":"2026-05-09T18:10:05Z","closed_at":"2026-05-09T18:31:02Z","close_reason":"Q2.4: constraint annotations surface as /// Constraint: doc comments by default. No client-side validation (validator_crate mode dropped per user feedback — OpenAPI constraints belong on wire contract). Q2.6: x-enum-varnames + x-enum-descriptions vendor extensions honored via SchemaAnalysis.enum_extensions side-channel, with length-mismatch dropping. Both default on, opt-out per toggle. 14 new tests. Spec-compile gate verification pending (running).","dependencies":[{"issue_id":"openapi-generator-d8y","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:05Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-61h","title":"[Q2.3] Typed BTreeMap from additionalProperties schema (default on)","description":"src/analysis.rs:1485 currently downgrades schema-typed additionalProperties to a bool, losing the value-type info. When additionalProperties is itself a schema, we should produce a BTreeMap\u003cString, T\u003e field on the struct (with #[serde(flatten)]) so users can carry typed extra fields. Toggle: [generator.types.shape] additional_properties_typed = true (default).\n\n## Context\nFiles: src/analysis.rs:1485 (additionalProperties handling), src/generator.rs (struct emission). Evidence: existing snapshot src/snapshots/openapi_to_rust__test_helpers__debug_additional_properties.snap already shows the BTreeMap shape but with serde_json::Value — we have the rendering, just need to thread the value-schema type through. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] additionalProperties: \u003cschema\u003e → BTreeMap\u003cString, T\u003e where T is the resolved schema type.\n- [ ] Field emitted with #[serde(flatten)] so named props still serialize alongside.\n- [ ] [generator.types.shape] additional_properties_typed = false reverts to current behavior (Value).\n- [ ] All 49 specs still compile.","status":"in_progress","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:41Z","created_by":"James Lal","updated_at":"2026-05-09T18:00:43Z","started_at":"2026-05-09T18:00:42Z","dependencies":[{"issue_id":"openapi-generator-61h","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-gub","title":"[Q2.2] Format alias normalization (uuid4, unix-time built-in)","description":"Vendor specs use non-standard format strings like 'uuid4' (372 occurrences across specs/) that should normalize to 'uuid' before standard mapping. Add [generator.types.format_aliases] TOML map applied before TypeMapper.string_format/integer_format dispatch. Defaults baked in: uuid4 → uuid, unix-time → int64. Users can extend.\n\n## Context\nFiles: src/type_mapping.rs (new in Q2.0), src/config.rs. Evidence: 'uuid4' appears 372 times in specs/, 'unix-time' appears in several. Today both fall through to bare 'String'. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.types.format_aliases] TOML map parses and merges into TypeMapper.\n- [ ] Built-in defaults: uuid4 → uuid, unix-time → int64.\n- [ ] Aliases applied before standard format dispatch (so 'uuid4' produces uuid::Uuid when uuid mapping is on).\n- [ ] User-provided alias overrides built-in default.\n- [ ] All 49 specs still compile.","status":"in_progress","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:33Z","created_by":"James Lal","updated_at":"2026-05-09T17:56:10Z","started_at":"2026-05-09T17:56:09Z","dependencies":[{"issue_id":"openapi-generator-gub","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-bw1","title":"[Q2.1] Honor uint32/uint64 integer formats (default on)","description":"src/analysis.rs:3258 get_number_rust_type only handles int32/int64, falling back to i64 for everything else. Real specs use uint32/uint64 ~288 times — they currently degrade to i64, hiding the unsigned semantic and risking overflow on the boundary. Map to u32/u64 by default. Toggle: [generator.types] unsigned = true (default true).\n\n## Context\nFiles: src/analysis.rs:3258 (get_number_rust_type). Evidence: grep over specs/ shows uint32/uint64 appearing 288+ times with no special handling. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] uint32 → u32, uint64 → u64 by default.\n- [ ] [generator.types] unsigned = false reverts to i64.\n- [ ] All 49 specs still compile under default (typed) config.\n- [ ] Snapshot test on a uint64-using spec confirms u64 emission.","status":"in_progress","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:30Z","created_by":"James Lal","updated_at":"2026-05-09T17:56:09Z","started_at":"2026-05-09T17:56:00Z","dependencies":[{"issue_id":"openapi-generator-bw1","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:03Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-r36","title":"[Q2.0] TypeMapper chokepoint for format-driven type mapping","description":"Centralize all openapi → rust type-mapping decisions into a single TypeMapper struct in src/type_mapping.rs (new). Today two sites map types — src/analysis.rs:2967 (openapi_type_to_rust_type) and src/analysis.rs:1151 (Typed/TypedMulti arm of analyze_schema_value) — and both ignore the 'format' field for strings. Rather than scatter format-handling across both, introduce TypeMapper which returns a MappedType { rust: TokenStream, serde_with: Option\u003cTokenStream\u003e, feature: Option\u003cTypeFeature\u003e }. The serde_with field carries codec hints (#[serde(with = ...)]) so generator.rs can attach them to the field. The feature field lets us track which optional crates the generator actually used, driving REQUIRED_DEPS advisory (Q2.8). This is the foundation for all other Q2.* work.\n\n## Context\nFiles: src/type_mapping.rs (new), src/analysis.rs:1151, src/analysis.rs:2967, src/generator.rs, src/config.rs, src/generator.rs (GeneratorConfig). Evidence: 2 separate type-mapping sites today; neither inspects details.format for strings. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] src/type_mapping.rs introduces TypeMapper + MappedType.\n- [ ] Both analysis.rs:1151 and analysis.rs:2967 route through TypeMapper.\n- [ ] TypeMapper threads from GeneratorConfig.types into SchemaAnalysis.\n- [ ] No behavior change in this issue: defaults preserve current output.\n- [ ] All 49 specs still compile.\n- [ ] Snapshot tests confirm bit-identical output before/after refactor.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:25Z","created_by":"James Lal","updated_at":"2026-05-09T06:41:50Z","started_at":"2026-05-09T05:40:52Z","closed_at":"2026-05-09T06:41:50Z","close_reason":"TypeMapper chokepoint introduced in src/type_mapping.rs; both analysis.rs:1151 (Typed/TypedMulti arm) and analysis.rs:2967 (openapi_type_to_rust_type) routed through it. Threaded from GeneratorConfig.types via SchemaAnalyzer::with_type_mapper. Default config preserves pre-refactor output: all 54 specs in spec-compile gate pass cleanly; full integration test suite passes with zero snapshot diffs; 5 new TypeMapper unit tests added. Acceptance criteria met.","dependency_count":0,"dependent_count":9,"comment_count":0} {"id":"openapi-generator-8tu","title":"[Q4] Tagged discriminator enums (drop untagged when discriminator+mapping is present)","description":"When a schema has discriminator: { propertyName: 'type', mapping: { ... } }, we know exactly which type to deserialize at runtime by reading one field. Yet today we still emit #[serde(untagged)] on the union enum, which makes serde try every variant in order on every deserialization (slow) and emits the variant payload's JSON inline instead of a tagged shape on serialization (loses the discriminator on round-trip). Anthropic's content blocks (text/image/tool_use/tool_result) and OpenAI's response items are exactly this pattern. Tagged is much better. Approach: in generate_discriminated_enum, when the spec provides discriminator with mapping, emit #[serde(tag = '\u003cdiscriminator.property_name\u003e')] and rename each variant to the mapping value. For unions WITHOUT a discriminator, untagged remains.\n\n## Context\nFiles: src/generator.rs. Evidence: src/generator.rs:1107 generate_discriminated_enum and 1251 generate_union_enum both emit #[serde(untagged)] regardless of discriminator presence. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] Discriminator + mapping → #[serde(tag = ...)] enum, not untagged.\n- [ ] Round-trip test: deserialize a JSON sample, serialize back, byte-equal modulo whitespace.\n- [ ] Variants ordered to match mapping insertion order (deterministic codegen).\n- [ ] Pet/Cat/Dog allOf-parent pattern (umbrella H12) supported.\n- [ ] All 49 currently-compiling specs still compile.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:13:12Z","created_by":"James Lal","updated_at":"2026-05-08T23:13:12Z","labels":["phase4","quality","schema"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-st8","title":"[Q3] Builder pattern for operations with many parameters","description":"OpenAI's responses_create has 25+ parameters. Even with Option\u003cT\u003e for optionals, the call site is hostile: client.responses_create(model, None, None, ..., Some('system prompt'), None, ...). Goal: emit a \u003cOp\u003eBuilder\u003c'_\u003e per op with .field(value) setters and a final .send().await. Required path/header params remain positional on the entry method; optional + body fields become builder setters. For struct-typed bodies, also generate per-field setters on the builder (delegating into the body struct).\n\n## Context\nFiles: src/client_generator.rs. Evidence: src/client_generator.rs:836 generate_request_param emits flat positional method args. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.builders] enabled = true; threshold = 3 in TOML config.\n- [ ] Each operation with \u003ethreshold optional params gets a builder struct.\n- [ ] Required params stay positional on the entry method.\n- [ ] .send(self) -\u003e Result\u003c\u003cResponseT\u003e, ApiOpError\u003c...\u003e\u003e runs the existing emitted body.\n- [ ] Snapshot tests for an op with many optional params show the new shape compiles and the existing call compiles.\n- [ ] All 49 currently-compiling specs still compile.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:11:55Z","created_by":"James Lal","updated_at":"2026-05-08T23:11:55Z","labels":["codegen","phase4","quality"],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"openapi-generator-quq","title":"[Q2] Format-typed scalars (date-time, uuid, byte, binary, ipv4, ipv6, uri)","description":"Real-world specs use 'format' tags everywhere. Today everything collapses to String/Vec\u003cu8\u003e. Add typed scalars: date-time → chrono::DateTime\u003cUtc\u003e; date → chrono::NaiveDate; time → chrono::NaiveTime; duration → chrono::Duration; uuid → uuid::Uuid; byte → Vec\u003cu8\u003e + base64 serde; binary → bytes::Bytes; ipv4/ipv6 → std::net::Ipv*Addr; uri/url → url::Url. Configurable via [generator.types] TOML section with per-format choices (chrono vs time vs string, bytes vs vec_u8, etc.). Default: 'string' (current behavior, opt-in).\n\n## Context\nFiles: Cargo.toml, src/analysis.rs, src/generator.rs, scripts/spec-compile.sh. Evidence: src/analysis.rs:3091 get_number_rust_type only handles int32/int64/float/double; string format never produces typed scalars. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] All formats above accept a TOML override.\n- [ ] Default ('string') matches today's behavior — no spec regresses.\n- [ ] When chrono is selected, generated structs use chrono::serde::rfc3339 for format: date-time.\n- [ ] When uuid is selected, generated structs use uuid::Uuid (with serde feature).\n- [ ] byte round-trips via base64 (matches OAS spec).\n- [ ] One end-to-end fixture per format under tests/conformance/fixtures/schema/format-*.yaml proving the types deserialize a real example.\n- [ ] Generated crate's Cargo.toml gets the right feature-gated deps.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:11:40Z","created_by":"James Lal","updated_at":"2026-05-08T23:11:40Z","labels":["phase4","quality","schema"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-quq","title":"[Q2] Format-typed scalars (date-time, uuid, byte, binary, ipv4, ipv6, uri)","description":"Real-world specs use 'format' tags everywhere. Today everything collapses to String/Vec\u003cu8\u003e. This issue adds typed scalars to the generator with **on-by-default** behavior and per-format opt-out via [generator.types] TOML.\n\n## Defaults (flipped to opt-out model)\n\n| format | default strategy | rust type | opt-out |\n|---|---|---|---|\n| date-time | chrono | chrono::DateTime\u003cUtc\u003e | = \"string\" or \"time\" |\n| date | chrono | chrono::NaiveDate | = \"string\" or \"time\" |\n| time | chrono | chrono::NaiveTime | = \"string\" or \"time\" |\n| duration | chrono | chrono::Duration | = \"string\" or \"iso8601\" |\n| uuid | uuid | uuid::Uuid | = \"string\" |\n| byte | base64 | Vec\u003cu8\u003e + inline base64_serde mod | = \"string\" or \"vec_u8\" |\n| binary | bytes | bytes::Bytes | = \"string\" or \"vec_u8\" |\n| ipv4/ipv6 | std | std::net::Ipv*Addr | = \"string\" |\n| uri | url | url::Url | = \"string\" |\n| email | string (off) | String | = \"email_address\" to opt in |\n\n## Implementation\n\nGoes through new TypeMapper chokepoint (see Q2.0). Each used optional crate is reported via REQUIRED_DEPS.toml (see Q2.8).\n\n## Context\nFiles: src/analysis.rs (lines 2967, 1151), src/generator.rs, src/type_mapping.rs (new). Evidence: src/analysis.rs:2973 returns bare \"String\" for OpenApiSchemaType::String regardless of format. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.types] TOML section with per-format strategy strings.\n- [ ] Each format's default is on (typed) when crate is small/common; opt-out via = \"string\".\n- [ ] CLI --types-conservative flag sets all strategies back to \"string\" for regression bisects.\n- [ ] date-time uses chrono::serde::rfc3339 codec.\n- [ ] uuid uses uuid::Uuid with serde feature.\n- [ ] byte round-trips via base64 (inline mod base64_serde, no runtime crate).\n- [ ] binary uses bytes::Bytes with serde feature.\n- [ ] One conformance fixture per format under tests/conformance/fixtures/schema/format-*.yaml.\n- [ ] All 49 currently-compiling specs still compile under default config (i.e. with typed scalars on).\n- [ ] All 49 specs also still compile under --types-conservative.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:11:40Z","created_by":"James Lal","updated_at":"2026-05-09T08:59:12Z","started_at":"2026-05-09T06:44:01Z","closed_at":"2026-05-09T08:59:12Z","close_reason":"Q2 typed-scalar formats land with flipped defaults (chrono/uuid/url/bytes/std::net::Ip*Addr/base64+codec). TypeMappingConfig switched from Option\u003cString\u003e placeholders to enum-typed strategies (DateStrategy/UuidStrategy/ByteStrategy/...) with opt-out per format. Wired through SchemaType::Primitive's new serde_with field, surfaced via #[serde(with = ...)] in generator. base64_serde helper module (with Option submodule for nullable byte fields) emitted only when format:byte is actually used. type_lacks_default extended for chrono/url/time types. --types-conservative CLI flag collapses everything back to String for bisecting. spec-compile gate: all 54 specs pass with default typed-on config; 1 skipped (gitea, baseline). Integration suite: zero failures. New tests: 10 typed-scalar end-to-end + 7 TypeMapper unit tests. Email + duration kept off by default (email less universal; chrono::Duration's native serde is seconds, not ISO 8601 — proper duration support is a follow-up).","labels":["phase4","quality","schema"],"dependencies":[{"issue_id":"openapi-generator-quq","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:02Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"openapi-generator-99a","title":"[Q1] Method-name canonicalization","description":"Heuristic post-processor on snake-cased operationId: tokenize path template, drop trailing tokens that match path tokens (in reverse path order), drop trailing HTTP-method verb. Re-check uniqueness; restore tokens for collisions. Goal: Anthropic's betaGetFileMetadataV1FilesFileIdGet + path /v1/files/{fileId} + GET → get_file_metadata.\n\n## Context\nToday get_method_name emits op.operation_id.to_snake_case() verbatim. Anthropic's spec produces names like beta_get_file_metadata_v1_files_file_id_get — the path and HTTP method are literally appended into the operationId. See umbrella issue gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] Heuristic implemented in src/client_generator.rs:get_method_name (line ~859).\n- [ ] Unique across operation set; collisions fall back to original.\n- [ ] CLI/config flag [generator.method_names] strip_path = true (default true).\n- [ ] Snapshot tests confirm anthropic produces get_file_metadata not beta_get_file_metadata_v1_files_file_id_get.\n- [ ] All 49 currently-compiling specs still compile.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:10:47Z","created_by":"James Lal","updated_at":"2026-05-08T23:10:47Z","labels":["codegen","phase4","quality"],"dependencies":[{"issue_id":"openapi-generator-99a","depends_on_id":"openapi-generator-st8","type":"blocks","created_at":"2026-05-08T17:11:55Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-tv8","title":"[Q2.5] Optional BTreeSet for uniqueItems arrays (opt-in)","description":"Arrays with uniqueItems: true (13,276 occurrences across specs/) currently emit Vec\u003cT\u003e. Spec-faithful representation is a set. Add [generator.types.shape] unique_items_to_set = false (default) — opt-in to emit BTreeSet\u003cT\u003e instead of Vec\u003cT\u003e. Off by default because flipping this changes the public API of every uniqueItems field across the corpus.\n\n## Context\nFiles: src/type_mapping.rs (Q2.0), src/analysis.rs (array analysis), src/generator.rs. Evidence: 13,276 uniqueItems usages in specs/, today all become Vec. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.types.shape] unique_items_to_set toggle works.\n- [ ] When on and item type implements Ord + Eq (primitives, strings, enums, named structs deriving them), array becomes BTreeSet\u003cT\u003e.\n- [ ] When on but item type isn't Ord (e.g. floats, complex unions), fall back to Vec\u003cT\u003e with a stderr warning naming the field.\n- [ ] All 49 specs still compile in default (off) mode.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:36:01Z","created_by":"James Lal","updated_at":"2026-05-09T05:36:01Z","dependencies":[{"issue_id":"openapi-generator-tv8","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-81u","title":"[Q5] Display for ApiOpError that surfaces the typed body","description":"Today format!('{e}', e: ApiOpError\u003cE\u003e) on an Api variant prints 'API error 404: {full body}'. For a Stripe error that includes a huge param_documentation blob, the message becomes a wall of JSON. Users complain they can't tell at a glance what the typed variant captured. Approach: in ApiError::Display, truncate body to ~500 chars with a '… (truncated)' marker; if typed.is_some(), prepend '(typed: \u003cvariant_name\u003e)' (E: fmt::Debug bound already exists); if parse_error.is_some() and typed.is_none(), append '(parse error: …)'. Full body still accessible via .body field.\n\n## Context\nFiles: src/http_error.rs. Evidence: src/http_error.rs:234 ApiError Display prints body verbatim — for huge JSON bodies this is unreadable; typed.is_some() info is hidden. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] ApiError Display truncates body at 500 chars (configurable via const).\n- [ ] Typed variant name appears when typed.is_some().\n- [ ] Parse error reason appears when typed parsing failed.\n- [ ] Full body still accessible via .body — no info loss.\n- [ ] Unit test in src/http_error.rs covers all three branches.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:13:13Z","created_by":"James Lal","updated_at":"2026-05-08T23:13:13Z","labels":["codegen","phase4","quality"],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/examples/complete_workflow.rs b/examples/complete_workflow.rs index 296fe18..b953a51 100644 --- a/examples/complete_workflow.rs +++ b/examples/complete_workflow.rs @@ -313,6 +313,7 @@ fn demonstrate_rust_api( schema_extensions: vec![], enable_registry: false, registry_only: false, + types: openapi_to_rust::TypeMappingConfig::default(), }; // Generate code diff --git a/examples/number_formats.rs b/examples/number_formats.rs index 02b17ff..67dfcb1 100644 --- a/examples/number_formats.rs +++ b/examples/number_formats.rs @@ -83,8 +83,9 @@ fn main() -> Result<(), Box> { openapi_to_rust::analysis::SchemaType::Object { properties, .. } => { let mut prop_types = Vec::new(); for (prop_name, prop_info) in properties { - if let openapi_to_rust::analysis::SchemaType::Primitive { rust_type } = - &prop_info.schema_type + if let openapi_to_rust::analysis::SchemaType::Primitive { + rust_type, .. + } = &prop_info.schema_type { prop_types.push(format!("{prop_name}: {rust_type}")); } else { diff --git a/scripts/spec-compile.sh b/scripts/spec-compile.sh index 841e365..c02fa74 100755 --- a/scripts/spec-compile.sh +++ b/scripts/spec-compile.sh @@ -100,7 +100,12 @@ reqwest-middleware = { version = "0.4", features = ["multipart"] } reqwest-retry = "0.7" reqwest-tracing = "0.5" thiserror = "1" -url = "2" +url = { version = "2", features = ["serde"] } +# Q2 typed-scalar deps (default-on; harmless when unused). +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["serde", "v4"] } +bytes = { version = "1", features = ["serde"] } +base64 = "0.22" EOF cat >"$dir/src/lib.rs" < Option { + let obj = original.as_object()?; + + let read_string_array = |key: &str| -> Option> { + let arr = obj.get(key)?.as_array()?; + let mut out = Vec::with_capacity(arr.len()); + for v in arr { + out.push(v.as_str()?.to_string()); + } + Some(out) + }; + + let varnames_raw = read_string_array("x-enum-varnames"); + let descriptions_raw = read_string_array("x-enum-descriptions"); + + if varnames_raw.is_none() && descriptions_raw.is_none() { + return None; + } + + let validate = |label: &str, vals: Option>| -> Vec { + let Some(vals) = vals else { + return Vec::new(); + }; + if vals.len() == enum_value_count { + vals + } else { + eprintln!( + "⚠️ {schema_name}: dropping {label} (expected {enum_value_count} entries, got {})", + vals.len() + ); + Vec::new() + } + }; + + let varnames = validate("x-enum-varnames", varnames_raw); + let descriptions = validate("x-enum-descriptions", descriptions_raw); + + if varnames.is_empty() && descriptions.is_empty() { + return None; + } + Some(EnumExtensions { + varnames, + descriptions, + }) +} + #[derive(Debug, Clone)] pub struct SchemaAnalysis { /// All schemas indexed by name @@ -14,6 +71,37 @@ pub struct SchemaAnalysis { pub patterns: DetectedPatterns, /// OpenAPI operations and their request/response schemas pub operations: BTreeMap, + /// Optional crates the [`TypeMapper`] was asked to reference + /// during analysis (e.g. chrono when a `format: date-time` field + /// became `chrono::DateTime`). The generator reads this to + /// decide which helper modules (e.g. `base64_serde`) to emit. + /// Q2.8 will additionally use it to write `REQUIRED_DEPS.toml`. + /// + /// [`TypeMapper`]: crate::type_mapping::TypeMapper + pub used_type_features: crate::type_mapping::UsedFeatures, + /// Q2.6: per-schema vendor enum extensions + /// (`x-enum-varnames` / `x-enum-descriptions`). Populated during + /// analysis when a StringEnum / ExtensibleEnum schema declares + /// either extension; the generator uses these to override the + /// default heuristic variant names and emit per-variant doc + /// comments. Indexed by analyzed-schema name. Side-channel so we + /// don't have to touch every StringEnum constructor. + pub enum_extensions: BTreeMap, +} + +/// Q2.6 — vendor extensions describing a string enum's variant +/// names and per-variant descriptions. Length must match the +/// schema's `enum` array; mismatched extensions are dropped at +/// analysis time with a warning. +#[derive(Debug, Clone, Default)] +pub struct EnumExtensions { + /// `x-enum-varnames`: Rust-friendly variant identifiers per + /// enum value, in the same order as the spec's `enum` array. + /// When present and length matches, the generator uses these + /// instead of its default PascalCase heuristic. + pub varnames: Vec, + /// `x-enum-descriptions`: one doc-comment per enum value. + pub descriptions: Vec, } #[derive(Debug, Clone)] @@ -29,13 +117,20 @@ pub struct AnalyzedSchema { #[derive(Debug, Clone)] pub enum SchemaType { - /// Simple primitive type - Primitive { rust_type: String }, + /// Simple primitive type. `serde_with` carries an optional + /// `#[serde(with = "")]` codec hint produced by the + /// TypeMapper for typed scalars (e.g. `format: byte` → + /// `Vec` + `base64_serde`); the generator wraps this in a + /// field-level `with = ...` attribute. + Primitive { + rust_type: String, + serde_with: Option, + }, /// Object with properties Object { properties: BTreeMap, required: HashSet, - additional_properties: bool, + additional_properties: ObjectAdditionalProperties, }, /// Discriminated union (oneOf + discriminator) DiscriminatedUnion { @@ -56,6 +151,31 @@ pub enum SchemaType { Reference { target: String }, } +/// How an Object handles `additionalProperties`. Q2.3 split the +/// pre-existing `bool` into a three-way enum so the generator can +/// emit a typed `BTreeMap` when the spec provides a +/// value-type schema instead of degrading to `serde_json::Value`. +#[derive(Debug, Clone)] +pub enum ObjectAdditionalProperties { + /// `additionalProperties: false` or absent — extra keys are + /// rejected and no extra field is emitted. + Forbidden, + /// `additionalProperties: true` — extra keys captured as + /// `BTreeMap`. + Untyped, + /// `additionalProperties: ` — extra keys captured as + /// `BTreeMap` where T comes from the schema. + Typed { value_type: Box }, +} + +impl ObjectAdditionalProperties { + /// True when extra keys are accepted (regardless of typing). + /// Used by callers that only care whether the field exists. + pub fn is_open(&self) -> bool { + !matches!(self, Self::Forbidden) + } +} + #[derive(Debug, Clone)] pub struct PropertyInfo { pub schema_type: SchemaType, @@ -63,6 +183,75 @@ pub struct PropertyInfo { pub description: Option, pub default: Option, pub serde_attrs: Vec, + /// Q2.4: OpenAPI constraint annotations captured from the + /// property schema. Surfaced by the generator as `/// Constraint: + /// …` doc lines and/or `#[validate(...)]` attributes depending on + /// `[generator.types.constraints] mode`. + pub constraints: PropertyConstraints, +} + +/// Q2.4 — per-property OpenAPI constraint annotations +/// (`minimum`/`maximum`/`minLength`/`maxLength`/`pattern`/etc.). +/// Populated during analysis from `SchemaDetails`; consumed by the +/// generator to emit doc comments and/or `#[validate(...)]` attrs. +#[derive(Debug, Clone, Default)] +pub struct PropertyConstraints { + pub minimum: Option, + pub maximum: Option, + pub exclusive_minimum: Option, + pub exclusive_maximum: Option, + pub multiple_of: Option, + pub min_length: Option, + pub max_length: Option, + pub pattern: Option, + pub min_items: Option, + pub max_items: Option, + pub unique_items: Option, +} + +impl PropertyConstraints { + pub fn is_empty(&self) -> bool { + self.minimum.is_none() + && self.maximum.is_none() + && self.exclusive_minimum.is_none() + && self.exclusive_maximum.is_none() + && self.multiple_of.is_none() + && self.min_length.is_none() + && self.max_length.is_none() + && self.pattern.is_none() + && self.min_items.is_none() + && self.max_items.is_none() + && self.unique_items.is_none() + } + + /// Capture the constraint-related fields off a `SchemaDetails`. + /// Exclusive bounds in OpenAPI 3.1 are numeric (`exclusiveMinimum: + /// 5`); we map the OAS-3.0 boolean flag form by leaving the + /// exclusive field unset and letting `minimum`/`maximum` carry it. + pub fn from_schema_details(details: &crate::openapi::SchemaDetails) -> Self { + use crate::openapi::ExclusiveBound; + let exclusive_minimum = match &details.exclusive_minimum { + Some(ExclusiveBound::Number(v)) => Some(*v), + _ => None, + }; + let exclusive_maximum = match &details.exclusive_maximum { + Some(ExclusiveBound::Number(v)) => Some(*v), + _ => None, + }; + Self { + minimum: details.minimum, + maximum: details.maximum, + exclusive_minimum, + exclusive_maximum, + multiple_of: details.multiple_of, + min_length: details.min_length, + max_length: details.max_length, + pattern: details.pattern.clone(), + min_items: details.min_items, + max_items: details.max_items, + unique_items: details.unique_items, + } + } } #[derive(Debug, Clone)] @@ -572,10 +761,25 @@ pub struct SchemaAnalyzer { openapi_spec: Value, current_schema_name: Option, component_parameters: BTreeMap, + /// Single chokepoint for `(openapi_type, format)` → Rust-type + /// decisions (Q2.0). Defaulted when the analyzer is built without a + /// config; threaded from `GeneratorConfig.types` via + /// [`Self::with_type_mapper`]. + type_mapper: TypeMapper, } impl SchemaAnalyzer { + /// Construct an analyzer with a default [`TypeMapper`]. Pre-Q2.0 + /// callers (tests, simple bins) use this and get bit-identical + /// behavior to the pre-refactor code. pub fn new(openapi_spec: Value) -> Result { + Self::with_type_mapper(openapi_spec, TypeMapper::default()) + } + + /// Construct an analyzer with a caller-supplied [`TypeMapper`] + /// (built from `GeneratorConfig.types`). The CLI / library entry + /// points use this so user TOML config drives type generation. + pub fn with_type_mapper(openapi_spec: Value, type_mapper: TypeMapper) -> Result { let spec: OpenApiSpec = serde_json::from_value(openapi_spec.clone()).map_err(GeneratorError::ParseError)?; let schemas = Self::extract_schemas(&spec)?; @@ -593,10 +797,12 @@ impl SchemaAnalyzer { openapi_spec, current_schema_name: None, component_parameters, + type_mapper, }) } - /// Create a new analyzer with schema extensions merged in + /// Create a new analyzer with schema extensions merged in (default + /// type mapper). pub fn new_with_extensions( openapi_spec: Value, extension_paths: &[std::path::PathBuf], @@ -605,6 +811,24 @@ impl SchemaAnalyzer { Self::new(merged_spec) } + /// Same as [`Self::new_with_extensions`] but with a caller-supplied + /// type mapper. + pub fn new_with_extensions_and_type_mapper( + openapi_spec: Value, + extension_paths: &[std::path::PathBuf], + type_mapper: TypeMapper, + ) -> Result { + let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?; + Self::with_type_mapper(merged_spec, type_mapper) + } + + /// Borrow the analyzer's type mapper. Useful for downstream + /// inspection (e.g. the dep advisory in Q2.8 reads + /// `type_mapper().used_features()` after generation). + pub fn type_mapper(&self) -> &TypeMapper { + &self.type_mapper + } + /// Generate a context-aware name for inline types, arrays, and variants /// This provides better naming than generic names like UnionArray1, InlineVariant2, etc. fn generate_context_aware_name( @@ -697,6 +921,8 @@ impl SchemaAnalyzer { type_mappings: BTreeMap::new(), }, operations: BTreeMap::new(), + used_type_features: crate::type_mapping::UsedFeatures::default(), + enum_extensions: BTreeMap::new(), }; // First pass: detect patterns @@ -779,6 +1005,26 @@ impl SchemaAnalyzer { } } + // Snapshot the type-mapper's used-features set so the + // generator can decide which helper modules to emit + // (e.g. base64_serde for `format: byte`). + analysis.used_type_features = self.type_mapper.used_features(); + + // Q2.6: capture x-enum-varnames / x-enum-descriptions from + // each enum schema's original JSON. Side-channel keyed by + // analyzed-schema name so we don't have to extend every + // SchemaType::StringEnum constructor. + for (name, analyzed) in &analysis.schemas { + let enum_value_count = match &analyzed.schema_type { + SchemaType::StringEnum { values } => values.len(), + SchemaType::ExtensibleEnum { known_values } => known_values.len(), + _ => continue, + }; + if let Some(ext) = extract_enum_extensions(&analyzed.original, enum_value_count, name) { + analysis.enum_extensions.insert(name.clone(), ext); + } + } + Ok(analysis) } @@ -1114,6 +1360,7 @@ impl SchemaAnalyzer { ); SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, } } } @@ -1147,28 +1394,29 @@ impl SchemaAnalyzer { .schema_type() .cloned() .unwrap_or(OpenApiSchemaType::Object); + let format = details.format.as_deref(); match primary { OpenApiSchemaType::String => { if let Some(values) = details.string_enum_values() { SchemaType::StringEnum { values } } else { SchemaType::Primitive { - rust_type: "String".to_string(), + rust_type: self.type_mapper.string_format(format).rust_type, + serde_with: None, } } } - OpenApiSchemaType::Integer => { - let rust_type = - self.get_number_rust_type(OpenApiSchemaType::Integer, details); - SchemaType::Primitive { rust_type } - } - OpenApiSchemaType::Number => { - let rust_type = - self.get_number_rust_type(OpenApiSchemaType::Number, details); - SchemaType::Primitive { rust_type } - } + OpenApiSchemaType::Integer => SchemaType::Primitive { + rust_type: self.type_mapper.integer_format(format).rust_type, + serde_with: None, + }, + OpenApiSchemaType::Number => SchemaType::Primitive { + rust_type: self.type_mapper.number_format(format).rust_type, + serde_with: None, + }, OpenApiSchemaType::Boolean => SchemaType::Primitive { - rust_type: "bool".to_string(), + rust_type: self.type_mapper.boolean().rust_type, + serde_with: None, }, OpenApiSchemaType::Array => { // Analyze array item type @@ -1178,7 +1426,8 @@ impl SchemaAnalyzer { // Check if this is a dynamic JSON object if self.should_use_dynamic_json(schema) { SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), + rust_type: self.type_mapper.dynamic_json().rust_type, + serde_with: None, } } else { // Analyze object properties @@ -1186,7 +1435,8 @@ impl SchemaAnalyzer { } } _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), + rust_type: self.type_mapper.dynamic_json().rust_type, + serde_with: None, }, } } @@ -1228,6 +1478,7 @@ impl SchemaAnalyzer { if self.should_use_dynamic_json(schema) { SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, } } else { self.analyze_object_schema(schema, &mut dependencies)? @@ -1240,11 +1491,13 @@ impl SchemaAnalyzer { } _ => SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }, } } else { SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, } } } @@ -1285,6 +1538,7 @@ impl SchemaAnalyzer { // This is a dynamic JSON pattern, use serde_json::Value directly SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, } } else if prop_schema.is_nullable_pattern() && let Some(non_null) = prop_schema.non_null_variant() @@ -1394,6 +1648,7 @@ impl SchemaAnalyzer { description: prop_description, default: prop_default, serde_attrs: Vec::new(), + constraints: PropertyConstraints::from_schema_details(prop_details), }, ); continue; @@ -1476,21 +1731,46 @@ impl SchemaAnalyzer { description: prop_description, default: prop_default, serde_attrs: Vec::new(), + constraints: PropertyConstraints::from_schema_details(prop_details), }, ); } } - // Check additionalProperties setting + // Q2.3: classify additionalProperties three ways. When the + // spec gives us a schema we analyze it and emit a typed + // BTreeMap; pre-Q2.3 collapsed both Schema and + // Boolean(true) to the same untyped map. Toggle: + // [generator.types.shape] additional_properties_typed + // Default true; setting false reverts the schema case to + // Untyped (current pre-Q2.3 behavior). + let typed_enabled = self + .type_mapper + .config() + .shape + .as_ref() + .and_then(|s| s.additional_properties_typed) + .unwrap_or(true); + let additional_properties = match &details.additional_properties { - Some(crate::openapi::AdditionalProperties::Boolean(true)) => true, - Some(crate::openapi::AdditionalProperties::Boolean(false)) => false, + Some(crate::openapi::AdditionalProperties::Boolean(true)) => { + ObjectAdditionalProperties::Untyped + } + Some(crate::openapi::AdditionalProperties::Boolean(false)) => { + ObjectAdditionalProperties::Forbidden + } + Some(crate::openapi::AdditionalProperties::Schema(value_schema)) if typed_enabled => { + let analyzed = + self.analyze_property_schema_with_context(value_schema, None, dependencies)?; + ObjectAdditionalProperties::Typed { + value_type: Box::new(analyzed), + } + } Some(crate::openapi::AdditionalProperties::Schema(_)) => { - // For now, treat schema-based additionalProperties as true - // TODO: Could analyze the schema to determine the value type - true + // typed_enabled = false: degrade to the pre-Q2.3 behavior. + ObjectAdditionalProperties::Untyped } - None => false, // Default is false if not specified + None => ObjectAdditionalProperties::Forbidden, }; Ok(SchemaType::Object { @@ -1527,6 +1807,7 @@ impl SchemaAnalyzer { ); return Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }); } } @@ -1664,19 +1945,32 @@ impl SchemaAnalyzer { target: enum_type_name, }); } else { + // Property-level string with no enum values: + // route through TypeMapper so `format: date-time` + // / `uuid` / etc. surface as typed scalars + // (chrono::DateTime, uuid::Uuid, …) instead of + // collapsing to bare `String`. + let mapped = self + .type_mapper + .string_format(schema.details().format.as_deref()); return Ok(SchemaType::Primitive { - rust_type: "String".to_string(), + rust_type: mapped.rust_type, + serde_with: mapped.serde_with, }); } } OpenApiSchemaType::Integer | OpenApiSchemaType::Number => { let details = schema.details(); let rust_type = self.get_number_rust_type(schema_type.clone(), details); - return Ok(SchemaType::Primitive { rust_type }); + return Ok(SchemaType::Primitive { + rust_type, + serde_with: None, + }); } OpenApiSchemaType::Boolean => { return Ok(SchemaType::Primitive { rust_type: "bool".to_string(), + serde_with: None, }); } OpenApiSchemaType::Array => { @@ -1700,6 +1994,7 @@ impl SchemaAnalyzer { if self.should_use_dynamic_json(schema) { return Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }); } // Inline object in property - create a named schema for it @@ -1746,6 +2041,7 @@ impl SchemaAnalyzer { _ => { return Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }); } } @@ -1766,6 +2062,7 @@ impl SchemaAnalyzer { if self.should_use_dynamic_json(schema) { return Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }); } @@ -1886,6 +2183,7 @@ impl SchemaAnalyzer { if self.should_use_dynamic_json(schema) { return Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }); } return self.analyze_object_schema(schema, dependencies); @@ -1913,19 +2211,24 @@ impl SchemaAnalyzer { } else { return Ok(SchemaType::Primitive { rust_type: "String".to_string(), + serde_with: None, }); } } _ => { // Handle other inferred types let rust_type = self.openapi_type_to_rust_type(inferred_type, schema.details()); - return Ok(SchemaType::Primitive { rust_type }); + return Ok(SchemaType::Primitive { + rust_type, + serde_with: None, + }); } } } Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }) } @@ -2038,7 +2341,7 @@ impl SchemaAnalyzer { Ok(SchemaType::Object { properties: merged_properties, required: merged_required, - additional_properties: false, + additional_properties: ObjectAdditionalProperties::Forbidden, }) } else { // Fall back to composition if we couldn't merge @@ -2092,6 +2395,7 @@ impl SchemaAnalyzer { description: prop_details.description.clone(), default: prop_details.default.clone(), serde_attrs: Vec::new(), + constraints: PropertyConstraints::from_schema_details(prop_details), }, ); } @@ -2355,7 +2659,7 @@ impl SchemaAnalyzer { match &variant_type { // For primitive types, we can use them directly in the union - SchemaType::Primitive { rust_type } => { + SchemaType::Primitive { rust_type, .. } => { union_variants.push(SchemaRef { target: rust_type.clone(), nullable: false, @@ -2364,7 +2668,7 @@ impl SchemaAnalyzer { // For arrays, check if we can determine the item type SchemaType::Array { item_type } => { match item_type.as_ref() { - SchemaType::Primitive { rust_type } => { + SchemaType::Primitive { rust_type, .. } => { let type_name = format!("Vec<{rust_type}>"); union_variants.push(SchemaRef { target: type_name, @@ -2432,6 +2736,7 @@ impl SchemaAnalyzer { // Only fall back to serde_json::Value if we truly can't analyze the union return Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }); } @@ -2508,7 +2813,7 @@ impl SchemaAnalyzer { match &variant_type { // For primitive types, we can use them directly in the union - SchemaType::Primitive { rust_type } => { + SchemaType::Primitive { rust_type, .. } => { union_variants.push(SchemaRef { target: rust_type.clone(), nullable: false, @@ -2517,7 +2822,7 @@ impl SchemaAnalyzer { // For arrays, check if we can determine the item type SchemaType::Array { item_type } => { match item_type.as_ref() { - SchemaType::Primitive { rust_type } => { + SchemaType::Primitive { rust_type, .. } => { let type_name = format!("Vec<{rust_type}>"); union_variants.push(SchemaRef { target: type_name, @@ -2536,7 +2841,7 @@ impl SchemaAnalyzer { item_type: inner_item_type, } => { match inner_item_type.as_ref() { - SchemaType::Primitive { rust_type } => { + SchemaType::Primitive { rust_type, .. } => { let type_name = format!("Vec>"); union_variants.push(SchemaRef { target: type_name, @@ -2624,6 +2929,7 @@ impl SchemaAnalyzer { // Only fall back to serde_json::Value if we truly can't analyze the union Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }) } @@ -2649,7 +2955,10 @@ impl SchemaAnalyzer { AnalyzedSchema { name: type_name.to_string(), original: serde_json::to_value(schema).unwrap_or(Value::Null), - schema_type: SchemaType::Primitive { rust_type }, + schema_type: SchemaType::Primitive { + rust_type, + serde_with: None, + }, dependencies: HashSet::new(), nullable: false, description: schema.details().description.clone(), @@ -2969,15 +3278,11 @@ impl SchemaAnalyzer { openapi_type: OpenApiSchemaType, details: &crate::openapi::SchemaDetails, ) -> String { - match openapi_type { - OpenApiSchemaType::String => "String".to_string(), - OpenApiSchemaType::Integer => self.get_number_rust_type(openapi_type, details), - OpenApiSchemaType::Number => self.get_number_rust_type(openapi_type, details), - OpenApiSchemaType::Boolean => "bool".to_string(), - OpenApiSchemaType::Array => "Vec".to_string(), // Fallback for arrays without items - OpenApiSchemaType::Object => "serde_json::Value".to_string(), // Fallback for untyped objects - OpenApiSchemaType::Null => "()".to_string(), // Null type - } + // Q2.0: route through the TypeMapper chokepoint. With the default + // config this produces bit-identical output to the pre-refactor + // match; later Q2.* issues add format-aware branches inside + // TypeMapper without touching this function. + self.type_mapper.map(openapi_type, details).rust_type } #[allow(dead_code)] @@ -3106,14 +3411,19 @@ impl SchemaAnalyzer { match schema_type { OpenApiSchemaType::String => SchemaType::Primitive { rust_type: "String".to_string(), + serde_with: None, }, OpenApiSchemaType::Integer | OpenApiSchemaType::Number => { let details = items_schema.details(); let rust_type = self.get_number_rust_type(schema_type.clone(), details); - SchemaType::Primitive { rust_type } + SchemaType::Primitive { + rust_type, + serde_with: None, + } } OpenApiSchemaType::Boolean => SchemaType::Primitive { rust_type: "bool".to_string(), + serde_with: None, }, OpenApiSchemaType::Object => { // Inline object in array - create a named schema for it @@ -3154,6 +3464,7 @@ impl SchemaAnalyzer { } _ => SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }, } } @@ -3220,27 +3531,35 @@ impl SchemaAnalyzer { } OpenApiSchemaType::String => SchemaType::Primitive { rust_type: "String".to_string(), + serde_with: None, }, OpenApiSchemaType::Integer | OpenApiSchemaType::Number => { let details = items_schema.details(); let rust_type = self.get_number_rust_type(inferred, details); - SchemaType::Primitive { rust_type } + SchemaType::Primitive { + rust_type, + serde_with: None, + } } OpenApiSchemaType::Boolean => SchemaType::Primitive { rust_type: "bool".to_string(), + serde_with: None, }, _ => SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }, } } else { SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, } } } _ => SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }, }; @@ -3251,6 +3570,7 @@ impl SchemaAnalyzer { // No items specified, fall back to generic array Ok(SchemaType::Primitive { rust_type: "Vec".to_string(), + serde_with: None, }) } } @@ -3260,24 +3580,14 @@ impl SchemaAnalyzer { schema_type: OpenApiSchemaType, details: &crate::openapi::SchemaDetails, ) -> String { + // Q2.0: delegate to the TypeMapper chokepoint. The fallback for + // non-numeric inputs is preserved for backwards compatibility + // (callers in 2025-era code path `Integer | Number` here). + let format = details.format.as_deref(); match schema_type { - OpenApiSchemaType::Integer => { - // Check format field for integer types - match details.format.as_deref() { - Some("int32") => "i32".to_string(), - Some("int64") => "i64".to_string(), - _ => "i64".to_string(), // Default for integer - } - } - OpenApiSchemaType::Number => { - // Check format field for number types - match details.format.as_deref() { - Some("float") => "f32".to_string(), - Some("double") => "f64".to_string(), - _ => "f64".to_string(), // Default for number - } - } - _ => "serde_json::Value".to_string(), // Fallback + OpenApiSchemaType::Integer => self.type_mapper.integer_format(format).rust_type, + OpenApiSchemaType::Number => self.type_mapper.number_format(format).rust_type, + _ => self.type_mapper.dynamic_json().rust_type, } } @@ -3305,6 +3615,7 @@ impl SchemaAnalyzer { if filtered_owned.is_empty() { return Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }); } if filtered_owned.len() == 1 { @@ -3445,68 +3756,86 @@ impl SchemaAnalyzer { nullable: false, }); } else if let Some(schema_type) = schema.schema_type() { - // Handle primitive types by creating type aliases for consistency - let inline_index = variants.len(); - - // Generate a better name for primitive types - let inline_type_name = match schema_type { - OpenApiSchemaType::String => { - // For string types, check if we can infer a better name from context - // If this is the first variant and it's a string, use a simple name - if inline_index == 0 { - format!("{context_name}String") - } else { - format!("{context_name}StringVariant{inline_index}") + // Q2.7: when `primitive_unions` is on (default), + // emit the Rust type directly as the variant + // target — matches `analyze_untagged_oneof_union` + // and produces a clean + // #[serde(untagged)] pub enum Foo { String(String), Integer(i64) } + // Pre-Q2.7 / opt-out emits a type alias per + // primitive (`pub type FooString = String`) and + // references the alias in the variant — works + // but adds noise. + let primitive_unions = self + .type_mapper + .config_shape_primitive_unions() + .unwrap_or(true); + + if primitive_unions { + let mapped = self.type_mapper.map(schema_type.clone(), schema.details()); + variants.push(SchemaRef { + target: mapped.rust_type, + nullable: false, + }); + } else { + let inline_index = variants.len(); + let inline_type_name = match schema_type { + OpenApiSchemaType::String => { + if inline_index == 0 { + format!("{context_name}String") + } else { + format!("{context_name}StringVariant{inline_index}") + } } - } - OpenApiSchemaType::Number => { - if inline_index == 0 { - format!("{context_name}Number") - } else { - format!("{context_name}NumberVariant{inline_index}") + OpenApiSchemaType::Number => { + if inline_index == 0 { + format!("{context_name}Number") + } else { + format!("{context_name}NumberVariant{inline_index}") + } } - } - OpenApiSchemaType::Integer => { - if inline_index == 0 { - format!("{context_name}Integer") - } else { - format!("{context_name}IntegerVariant{inline_index}") + OpenApiSchemaType::Integer => { + if inline_index == 0 { + format!("{context_name}Integer") + } else { + format!("{context_name}IntegerVariant{inline_index}") + } } - } - OpenApiSchemaType::Boolean => { - if inline_index == 0 { - format!("{context_name}Boolean") - } else { - format!("{context_name}BooleanVariant{inline_index}") + OpenApiSchemaType::Boolean => { + if inline_index == 0 { + format!("{context_name}Boolean") + } else { + format!("{context_name}BooleanVariant{inline_index}") + } } - } - _ => format!("{context_name}Variant{inline_index}"), - }; + _ => format!("{context_name}Variant{inline_index}"), + }; - let rust_type = - self.openapi_type_to_rust_type(schema_type.clone(), schema.details()); + let rust_type = + self.openapi_type_to_rust_type(schema_type.clone(), schema.details()); - // Store as a type alias - self.resolved_cache.insert( - inline_type_name.clone(), - AnalyzedSchema { - name: inline_type_name.clone(), - original: serde_json::to_value(schema).unwrap_or(Value::Null), - schema_type: SchemaType::Primitive { rust_type }, - dependencies: HashSet::new(), - nullable: false, - description: schema.details().description.clone(), - default: None, - }, - ); + self.resolved_cache.insert( + inline_type_name.clone(), + AnalyzedSchema { + name: inline_type_name.clone(), + original: serde_json::to_value(schema).unwrap_or(Value::Null), + schema_type: SchemaType::Primitive { + rust_type, + serde_with: None, + }, + dependencies: HashSet::new(), + nullable: false, + description: schema.details().description.clone(), + default: None, + }, + ); - // Add inline type as a dependency - dependencies.insert(inline_type_name.clone()); + dependencies.insert(inline_type_name.clone()); - variants.push(SchemaRef { - target: inline_type_name, - nullable: false, - }); + variants.push(SchemaRef { + target: inline_type_name, + nullable: false, + }); + } } } @@ -3555,6 +3884,7 @@ impl SchemaAnalyzer { // Pattern 4: Mixed primitives = fall back to serde_json::Value Ok(SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), + serde_with: None, }) } diff --git a/src/bin/openapi-to-rust.rs b/src/bin/openapi-to-rust.rs index 88b5bc0..8854196 100644 --- a/src/bin/openapi-to-rust.rs +++ b/src/bin/openapi-to-rust.rs @@ -18,6 +18,12 @@ enum Commands { /// Path to configuration file (openapi-to-rust.toml) #[arg(short, long, default_value = "openapi-to-rust.toml")] config: PathBuf, + /// Force every typed-scalar strategy back to "string" (Q2). + /// Useful for bisecting regressions caused by typed-scalar + /// adoption — overrides any `[generator.types]` settings in + /// the TOML config. + #[arg(long)] + types_conservative: bool, }, /// Validate configuration file without generating code Validate { @@ -48,7 +54,10 @@ async fn main() -> Result<(), Box> { } } } - Commands::Generate { config } => { + Commands::Generate { + config, + types_conservative, + } => { println!("📖 Reading configuration from: {}", config.display()); // Load configuration from TOML @@ -61,7 +70,15 @@ async fn main() -> Result<(), Box> { } }; - let generator_config = config_file.into_generator_config(); + let mut generator_config = config_file.into_generator_config(); + + // CLI override: `--types-conservative` collapses every + // Q2 typed-scalar strategy back to plain `String`. Useful + // for bisecting regressions caused by typed-scalar + // adoption without editing the TOML config. + if types_conservative { + generator_config.types = openapi_to_rust::TypeMappingConfig::conservative(); + } println!( "📄 Reading OpenAPI spec: {}", @@ -110,18 +127,22 @@ async fn main() -> Result<(), Box> { } } - // Analyze schemas (with extensions if configured) + // Analyze schemas (with extensions if configured). Build a + // TypeMapper from the user's [generator.types] config so + // per-format strategies drive type generation (Q2.0). println!("🔍 Analyzing schemas..."); + let type_mapper = openapi_to_rust::TypeMapper::new(generator_config.types.clone()); let mut analyzer = if generator_config.schema_extensions.is_empty() { - SchemaAnalyzer::new(spec_value)? + SchemaAnalyzer::with_type_mapper(spec_value, type_mapper)? } else { println!( "📎 Merging {} schema extension(s)", generator_config.schema_extensions.len() ); - SchemaAnalyzer::new_with_extensions( + SchemaAnalyzer::new_with_extensions_and_type_mapper( spec_value, &generator_config.schema_extensions, + type_mapper, )? }; let mut analysis = analyzer.analyze()?; @@ -143,6 +164,30 @@ async fn main() -> Result<(), Box> { generator.config().output_dir.display() ); + // Q2.8 dep advisory: surface optional crates the + // generated code references so the operator knows what + // to add to their Cargo.toml. write_files already + // dropped a copy-pasteable REQUIRED_DEPS.toml next to + // the generated module; the stderr summary makes it + // discoverable without scanning the output dir. + if !result.required_deps.is_empty() { + eprintln!(); + eprintln!( + "📦 Generated code uses {} optional crate(s). Add to your Cargo.toml:", + result.required_deps.len() + ); + eprintln!(); + eprintln!("[dependencies]"); + for dep in &result.required_deps { + eprintln!("{}", dep.to_toml_line()); + } + eprintln!(); + eprintln!( + "(Same content written to {}/REQUIRED_DEPS.toml)", + generator.config().output_dir.display() + ); + } + Ok(()) } } diff --git a/src/config.rs b/src/config.rs index 31bcb4d..cbf7853 100644 --- a/src/config.rs +++ b/src/config.rs @@ -160,6 +160,10 @@ pub struct ConfigFile { pub nullable_overrides: BTreeMap, #[serde(default)] pub type_mappings: BTreeMap, + /// `[generator.types]` block — per-format type-mapping strategies. + /// Wired in by Q2.0; populated in subsequent Q2.* issues. + #[serde(default)] + pub types: crate::type_mapping::TypeMappingConfig, } #[derive(Debug, Clone, Deserialize, Serialize, Validate)] @@ -550,6 +554,7 @@ impl ConfigFile { auth_config, enable_registry: self.features.enable_registry, registry_only: self.features.registry_only, + types: self.types, } } } diff --git a/src/generator.rs b/src/generator.rs index 9fdca2e..75c5756 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -4,6 +4,86 @@ use quote::{format_ident, quote}; use std::collections::BTreeMap; use std::path::PathBuf; +/// Parse a Rust type string (possibly with generics, e.g. +/// `chrono::DateTime`) into a `TokenStream`. The pre-Q2 +/// ad-hoc `::`-splitter choked on `<` and `>`; `syn::parse_str` handles +/// every valid type expression. Errors here mean the [`TypeMapper`] +/// produced a string that doesn't parse as a Rust type — a generator +/// bug, surfaced as a `GeneratorError::CodeGenError`. +/// +/// [`TypeMapper`]: crate::type_mapping::TypeMapper +fn parse_rust_type(rust_type: &str) -> Result { + let parsed: syn::Type = syn::parse_str(rust_type).map_err(|e| { + GeneratorError::CodeGenError(format!( + "TypeMapper produced un-parseable type `{rust_type}`: {e}" + )) + })?; + Ok(quote! { #parsed }) +} + +/// Q2.4 — render OpenAPI constraint annotations as a single-line +/// human-readable doc comment, e.g. +/// "Constraint: minimum=0, maximum=100, pattern=`^foo$`" +/// +/// The pattern is wrapped in backticks so backticks/braces inside +/// it don't trip prettyplease/rustdoc parsing. Triple-slash and +/// `*/` sequences are escaped so embedded patterns can't terminate +/// the surrounding doc comment / block comment. +fn format_constraints_doc(c: &crate::analysis::PropertyConstraints) -> String { + let mut parts: Vec = Vec::new(); + + if let Some(v) = c.minimum { + parts.push(format!("minimum={}", strip_trailing_zero(v))); + } + if let Some(v) = c.maximum { + parts.push(format!("maximum={}", strip_trailing_zero(v))); + } + if let Some(v) = c.exclusive_minimum { + parts.push(format!("exclusiveMinimum={}", strip_trailing_zero(v))); + } + if let Some(v) = c.exclusive_maximum { + parts.push(format!("exclusiveMaximum={}", strip_trailing_zero(v))); + } + if let Some(v) = c.multiple_of { + parts.push(format!("multipleOf={}", strip_trailing_zero(v))); + } + if let Some(v) = c.min_length { + parts.push(format!("minLength={v}")); + } + if let Some(v) = c.max_length { + parts.push(format!("maxLength={v}")); + } + if let Some(v) = c.min_items { + parts.push(format!("minItems={v}")); + } + if let Some(v) = c.max_items { + parts.push(format!("maxItems={v}")); + } + if c.unique_items == Some(true) { + parts.push("uniqueItems=true".to_string()); + } + if let Some(p) = &c.pattern { + // Insert a zero-width-space inside `///` and `*/` so they + // can't terminate the surrounding doc/block comment. Using + // the `\u{200B}` escape (vs. a literal U+200B) keeps clippy's + // `invisible_characters` lint happy. + let safe = p.replace("///", "/\u{200B}//").replace("*/", "*\u{200B}/"); + parts.push(format!("pattern=`{safe}`")); + } + + format!("Constraint: {}", parts.join(", ")) +} + +/// `1.0` and `1` should both render as `1` in doc comments. +/// `1.5` stays `1.5`. +fn strip_trailing_zero(v: f64) -> String { + if v.fract() == 0.0 && v.is_finite() { + format!("{}", v as i64) + } else { + format!("{v}") + } +} + /// Info about schemas that are variants in discriminated unions #[derive(Clone)] struct DiscriminatedVariantInfo { @@ -51,6 +131,10 @@ pub struct GeneratorConfig { pub enable_registry: bool, /// Generate only the operation registry (skip types, client, streaming) pub registry_only: bool, + /// Per-format type-mapping strategies driven by the `[generator.types]` + /// TOML section. Q2.0 introduces this field; with the default value + /// every mapping preserves pre-refactor behavior. + pub types: crate::type_mapping::TypeMappingConfig, } impl Default for GeneratorConfig { @@ -72,6 +156,7 @@ impl Default for GeneratorConfig { auth_config: None, enable_registry: false, registry_only: false, + types: crate::type_mapping::TypeMappingConfig::default(), } } } @@ -101,6 +186,14 @@ pub struct GenerationResult { pub files: Vec, /// Generated mod.rs content that exports all modules pub mod_file: GeneratedFile, + /// Optional crates the generated code references (chrono, uuid, + /// url, …) — populated from the analyzer's TypeMapper + /// used-features set. The CLI uses this to write + /// `REQUIRED_DEPS.toml` next to the generated module and to + /// print a stderr summary so users know exactly what to add to + /// their `Cargo.toml`. Empty when no typed-scalar crates were + /// referenced. + pub required_deps: Vec, } pub struct CodeGenerator { @@ -165,7 +258,17 @@ impl CodeGenerator { content: mod_content, }; - Ok(GenerationResult { files, mod_file }) + // Snapshot the optional crates the analyzer's TypeMapper + // touched. Q2.8 surfaces these via REQUIRED_DEPS.toml + // (written by `write_files`) and a CLI stderr summary. + let required_deps = + crate::type_mapping::collect_dep_requirements(&analysis.used_type_features); + + Ok(GenerationResult { + files, + mod_file, + required_deps, + }) } /// Generate just the types (legacy single-file interface) @@ -270,7 +373,77 @@ impl CodeGenerator { } } - // Generate file with imports and types (no module wrapper) + // Helper modules emitted only when the analyzer actually + // referenced their codecs. Avoids polluting every generated + // file (and every snapshot) with dead code for specs that + // don't use `format: byte`. + let base64_helper = if analysis + .used_type_features + .contains(crate::type_mapping::TypeFeature::Base64) + { + quote! { + /// base64 codec for `Vec` fields produced from + /// `format: byte`. Used via `#[serde(with = "base64_serde")]` + /// for required/non-null fields; `with = "base64_serde::option"` + /// for the Option> case. + mod base64_serde { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize( + bytes: &Vec, + ser: S, + ) -> Result { + ser.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + let s = String::deserialize(de)?; + STANDARD + .decode(s.as_bytes()) + .map_err(serde::de::Error::custom) + } + + /// Codec for Option> fields (optional / + /// nullable `format: byte`). serde dispatches on + /// the field type; without this submodule the + /// `?` operator in the generated code would fail + /// to convert Vec to Option>. + pub mod option { + use super::*; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize( + opt: &Option>, + ser: S, + ) -> Result { + match opt { + Some(bytes) => super::serialize(bytes, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result>, D::Error> { + let opt = Option::::deserialize(de)?; + opt.map(|s| { + STANDARD + .decode(s.as_bytes()) + .map_err(serde::de::Error::custom) + }) + .transpose() + } + } + } + } + } else { + TokenStream::new() + }; + + // Generate file with imports and types (no module wrapper). let generated = quote! { //! Generated types from OpenAPI specification //! @@ -284,6 +457,8 @@ impl CodeGenerator { use serde::{Deserialize, Serialize}; + #base64_helper + #type_definitions }; @@ -592,6 +767,16 @@ impl CodeGenerator { let mod_path = self.config.output_dir.join(&result.mod_file.path); fs::write(&mod_path, &result.mod_file.content)?; + // Q2.8: write REQUIRED_DEPS.toml when the generated code + // references any optional crates (chrono, uuid, url, …). + // Skipped silently when the set is empty so we don't litter + // the output dir for specs whose generated types only use + // std/serde/serde_json. + if let Some(toml) = crate::type_mapping::render_required_deps_toml(&result.required_deps) { + let deps_path = self.config.output_dir.join("REQUIRED_DEPS.toml"); + fs::write(&deps_path, toml)?; + } + Ok(()) } @@ -604,13 +789,17 @@ impl CodeGenerator { use crate::analysis::SchemaType; match &schema.schema_type { - SchemaType::Primitive { rust_type } => { + SchemaType::Primitive { rust_type, .. } => { // Generate type alias for primitives that are referenced by other schemas self.generate_type_alias(schema, rust_type) } - SchemaType::StringEnum { values } => self.generate_string_enum(schema, values), + SchemaType::StringEnum { values } => { + let ext = analysis.enum_extensions.get(&schema.name); + self.generate_string_enum(schema, values, ext) + } SchemaType::ExtensibleEnum { known_values } => { - self.generate_extensible_enum(schema, known_values) + let ext = analysis.enum_extensions.get(&schema.name); + self.generate_extensible_enum(schema, known_values, ext) } SchemaType::Object { properties, @@ -620,7 +809,7 @@ impl CodeGenerator { schema, properties, required, - *additional_properties, + additional_properties, analysis, discriminated_variant_info.get(&schema.name), ), @@ -741,23 +930,10 @@ impl CodeGenerator { rust_type: &str, ) -> Result { let type_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); - - // Parse the rust type into tokens - let base_type = if rust_type.contains("::") { - let parts: Vec<&str> = rust_type.split("::").collect(); - if parts.len() == 2 { - let module = format_ident!("{}", parts[0]); - let type_name_part = format_ident!("{}", parts[1]); - quote! { #module::#type_name_part } - } else { - // More complex path - let path_parts: Vec<_> = parts.iter().map(|p| format_ident!("{}", p)).collect(); - quote! { #(#path_parts)::* } - } - } else { - let simple_type = format_ident!("{}", rust_type); - quote! { #simple_type } - }; + // syn parses any valid Rust type expression including + // generics (`chrono::DateTime`, `Vec`). + // The pre-Q2 ad-hoc `::`-splitter choked on `<`. + let base_type = parse_rust_type(rust_type)?; let doc_comment = if let Some(desc) = &schema.description { let sanitized_desc = self.sanitize_doc_comment(desc); @@ -776,6 +952,7 @@ impl CodeGenerator { &self, schema: &crate::analysis::AnalyzedSchema, known_values: &[String], + ext: Option<&crate::analysis::EnumExtensions>, ) -> Result { let enum_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); @@ -785,29 +962,53 @@ impl CodeGenerator { TokenStream::new() }; + // Q2.6: pre-resolve variant idents from x-enum-varnames when + // available + length-matched + toggle on. Same fallback rule + // as generate_string_enum. + let varnames_override: Option<&Vec> = ext + .filter(|_| self.config.types.x_enum_varnames_enabled()) + .map(|e| &e.varnames) + .filter(|v| !v.is_empty() && v.len() == known_values.len()); + let descriptions_override: Option<&Vec> = ext + .filter(|_| self.config.types.x_enum_descriptions_enabled()) + .map(|e| &e.descriptions) + .filter(|v| !v.is_empty() && v.len() == known_values.len()); + + let variant_ident_for = |index: usize, value: &str| -> proc_macro2::Ident { + let name = match varnames_override { + Some(v) => v[index].clone(), + None => self.to_rust_enum_variant(value), + }; + format_ident!("{}", name) + }; + // For extensible enums, we need a different approach: // 1. Create a regular enum with known variants + Custom // 2. Implement custom serialization/deserialization - let known_variants = known_values.iter().map(|value| { - let variant_name = self.to_rust_enum_variant(value); - let variant_ident = format_ident!("{}", variant_name); + let known_variants = known_values.iter().enumerate().map(|(i, value)| { + let variant_ident = variant_ident_for(i, value); + let doc = descriptions_override + .map(|d| { + let s = self.sanitize_doc_comment(&d[i]); + quote! { #[doc = #s] } + }) + .unwrap_or_default(); quote! { + #doc #variant_ident, } }); - let match_arms_de = known_values.iter().map(|value| { - let variant_name = self.to_rust_enum_variant(value); - let variant_ident = format_ident!("{}", variant_name); + let match_arms_de = known_values.iter().enumerate().map(|(i, value)| { + let variant_ident = variant_ident_for(i, value); quote! { #value => Ok(#enum_name::#variant_ident), } }); - let match_arms_ser = known_values.iter().map(|value| { - let variant_name = self.to_rust_enum_variant(value); - let variant_ident = format_ident!("{}", variant_name); + let match_arms_ser = known_values.iter().enumerate().map(|(i, value)| { + let variant_ident = variant_ident_for(i, value); quote! { #enum_name::#variant_ident => #value, } @@ -865,6 +1066,7 @@ impl CodeGenerator { &self, schema: &crate::analysis::AnalyzedSchema, values: &[String], + ext: Option<&crate::analysis::EnumExtensions>, ) -> Result { let enum_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); @@ -884,6 +1086,18 @@ impl CodeGenerator { None => !values.is_empty(), }; + // Q2.6: x-enum-varnames overrides the default heuristic when + // present, length-matched, and the toggle is on. Falls back + // to the to_rust_enum_variant heuristic otherwise. + let varnames_override: Option<&Vec> = ext + .filter(|_| self.config.types.x_enum_varnames_enabled()) + .map(|e| &e.varnames) + .filter(|v| !v.is_empty() && v.len() == values.len()); + let descriptions_override: Option<&Vec> = ext + .filter(|_| self.config.types.x_enum_descriptions_enabled()) + .map(|e| &e.descriptions) + .filter(|v| !v.is_empty() && v.len() == values.len()); + // Variant-name uniqueness: enum values that PascalCase to the same // identifier (e.g. `ASC`/`asc` both → `Asc`) collide and produce // E0428 + non-exhaustive matches downstream. Dedupe by suffixing @@ -891,11 +1105,14 @@ impl CodeGenerator { // name, and keeping each variant's `#[serde(rename)]` pointed at the // original wire string. let mut used: std::collections::HashSet = std::collections::HashSet::new(); - let variant_pairs: Vec<(syn::Ident, &String, bool)> = values + let variant_pairs: Vec<(syn::Ident, &String, bool, Option)> = values .iter() .enumerate() .map(|(i, value)| { - let base = self.to_rust_enum_variant(value); + let base = match varnames_override { + Some(v) => v[i].clone(), + None => self.to_rust_enum_variant(value), + }; let mut variant_name = base.clone(); let mut suffix = 2; while !used.insert(variant_name.clone()) { @@ -908,31 +1125,42 @@ impl CodeGenerator { } else { i == 0 }; - (variant_ident, value, is_default) + let description = descriptions_override.map(|d| d[i].clone()); + (variant_ident, value, is_default, description) }) .collect(); - let variants = variant_pairs - .iter() - .map(|(variant_ident, value, is_default)| { - if *is_default { - quote! { - #[default] - #[serde(rename = #value)] - #variant_ident, - } - } else { - quote! { - #[serde(rename = #value)] - #variant_ident, + let variants = + variant_pairs + .iter() + .map(|(variant_ident, value, is_default, description)| { + let doc = description + .as_ref() + .map(|d| { + let s = self.sanitize_doc_comment(d); + quote! { #[doc = #s] } + }) + .unwrap_or_default(); + if *is_default { + quote! { + #doc + #[default] + #[serde(rename = #value)] + #variant_ident, + } + } else { + quote! { + #doc + #[serde(rename = #value)] + #variant_ident, + } } - } - }); + }); // T13/T10: emit `as_str` and `Display` so the enum can be embedded in // query strings, headers, and path segments without requiring callers // to reach for `serde_json` round-trips. - let as_str_arms = variant_pairs.iter().map(|(variant_ident, value, _)| { + let as_str_arms = variant_pairs.iter().map(|(variant_ident, value, _, _)| { quote! { Self::#variant_ident => #value, } }); @@ -995,7 +1223,7 @@ impl CodeGenerator { schema: &crate::analysis::AnalyzedSchema, properties: &BTreeMap, required: &std::collections::HashSet, - additional_properties: bool, + additional_properties: &crate::analysis::ObjectAdditionalProperties, analysis: &crate::analysis::SchemaAnalysis, discriminator_info: Option<&DiscriminatedVariantInfo>, ) -> Result { @@ -1056,9 +1284,11 @@ impl CodeGenerator { } else { TokenStream::new() }; + let constraint_doc = self.generate_constraint_doc(&prop.constraints); quote! { #doc_comment + #constraint_doc #serde_attrs #specta_attrs pub #field_ident: #field_type, @@ -1066,13 +1296,31 @@ impl CodeGenerator { }) .collect(); - // Add additional properties field if enabled - if additional_properties { - fields.push(quote! { - /// Additional properties not explicitly defined in the schema - #[serde(flatten)] - pub additional_properties: std::collections::BTreeMap, - }); + // Q2.3: emit the catch-all additional-properties field with + // the right value type. `Untyped` keeps pre-Q2.3 behavior + // (BTreeMap); `Typed { value_type }` + // surfaces the actual schema-declared type, e.g. + // BTreeMap. `Forbidden` emits no field. + match additional_properties { + crate::analysis::ObjectAdditionalProperties::Forbidden => {} + crate::analysis::ObjectAdditionalProperties::Untyped => { + fields.push(quote! { + /// Additional properties not explicitly defined in the schema + #[serde(flatten)] + pub additional_properties: + std::collections::BTreeMap, + }); + } + crate::analysis::ObjectAdditionalProperties::Typed { value_type } => { + let value_tokens = self.generate_array_item_type(value_type, analysis); + fields.push(quote! { + /// Additional properties matching the spec's + /// `additionalProperties` value schema. + #[serde(flatten)] + pub additional_properties: + std::collections::BTreeMap, + }); + } } let doc_comment = if let Some(desc) = &schema.description { @@ -1351,6 +1599,15 @@ impl CodeGenerator { }; quote! { Vec<#inner_type> } } + } else if variant.target.contains("::") || variant.target.contains('<') { + // Qualified Rust path or generic (chrono::DateTime, + // bytes::Bytes, std::net::Ipv4Addr) emitted by TypeMapper. Pass + // it straight to syn — the to_rust_type_name PascalCase + // pipeline below would mangle it into a non-existent ident. + parse_rust_type(&variant.target).unwrap_or_else(|_| { + let fallback = format_ident!("{}", self.to_rust_type_name(&variant.target)); + quote! { #fallback } + }) } else { let type_ident = format_ident!("{}", self.to_rust_type_name(&variant.target)); quote! { #type_ident } @@ -1451,24 +1708,20 @@ impl CodeGenerator { use crate::analysis::SchemaType; let base_type = match &prop.schema_type { - SchemaType::Primitive { rust_type } => { - // Handle complex types like serde_json::Value - if rust_type.contains("::") { - let parts: Vec<&str> = rust_type.split("::").collect(); - if parts.len() == 2 { - let module = format_ident!("{}", parts[0]); - let type_name = format_ident!("{}", parts[1]); - quote! { #module::#type_name } - } else { - // More than 2 parts, construct path - let path_parts: Vec<_> = - parts.iter().map(|p| format_ident!("{}", p)).collect(); - quote! { #(#path_parts)::* } - } - } else { - let type_ident = format_ident!("{}", rust_type); - quote! { #type_ident } - } + SchemaType::Primitive { rust_type, .. } => { + // syn handles generics + complex paths + // (chrono::DateTime, Vec, …). + parse_rust_type(rust_type).unwrap_or_else(|_| { + // Pathological mapper output: fall back to bare + // String so the generated file at least + // compiles. Emit a stderr warning so the + // operator can investigate. + eprintln!( + "⚠️ TypeMapper produced un-parseable type `{rust_type}`; \ + falling back to String" + ); + quote! { String } + }) } SchemaType::Reference { target } => { let target_rust_name = self.to_rust_type_name(target); @@ -1564,6 +1817,30 @@ impl CodeGenerator { attrs.push(quote! { default }); } + // Codec hint from TypeMapper (Q2): `format: byte` → + // `with = "base64_serde"`, etc. Fields whose mapped type + // carries no codec (e.g. chrono::DateTime uses its + // built-in serde) skip this attribute. Option fields need + // the `::option` submodule of the codec — serde dispatches + // on field type, and the base codec works on Vec / + // chrono::Duration / etc., not their Option wrappers. + if let crate::analysis::SchemaType::Primitive { + serde_with: Some(codec), + .. + } = &prop.schema_type + { + // Mirrors the wrapping logic in generate_field_type: + // the field is Option when the schema marks it + // optional or nullable. + let is_option_wrapped = !is_required || prop.nullable; + let codec_path = if is_option_wrapped { + format!("{codec}::option") + } else { + codec.clone() + }; + attrs.push(quote! { with = #codec_path }); + } + if attrs.is_empty() { TokenStream::new() } else { @@ -1582,6 +1859,22 @@ impl CodeGenerator { use crate::analysis::SchemaType; match schema_type { SchemaType::DiscriminatedUnion { .. } | SchemaType::Union { .. } => true, + // Q2 typed scalars: chrono / url have no Default impl. + // uuid::Uuid, bytes::Bytes, std::net::Ip*Addr all derive + // Default, so they're safe to leave under #[serde(default)]. + SchemaType::Primitive { rust_type, .. } => matches!( + rust_type.as_str(), + "chrono::DateTime" + | "chrono::NaiveDate" + | "chrono::NaiveTime" + | "chrono::Duration" + | "url::Url" + | "time::OffsetDateTime" + | "time::Date" + | "time::Time" + | "iso8601::Duration" + | "email_address::EmailAddress" + ), SchemaType::Reference { target } => { if let Some(schema) = analysis.schemas.get(target) { self.type_lacks_default(&schema.schema_type, analysis) @@ -1798,6 +2091,31 @@ impl CodeGenerator { } } + /// Q2.4: render a `/// Constraint: …` doc comment for a field + /// when its OpenAPI schema declares any constraint annotations. + /// No-op when constraints are empty or `mode = "off"`. + /// + /// **Doc-comment only** — by deliberate design we never emit + /// `#[validate(...)]` attributes. Constraints belong to the wire + /// contract; the server is the source of truth. + fn generate_constraint_doc( + &self, + constraints: &crate::analysis::PropertyConstraints, + ) -> TokenStream { + use crate::type_mapping::ConstraintMode; + + if constraints.is_empty() { + return TokenStream::new(); + } + match self.config.types.constraint_mode() { + ConstraintMode::Off => TokenStream::new(), + ConstraintMode::Doc => { + let formatted = format_constraints_doc(constraints); + quote! { #[doc = #formatted] } + } + } + } + fn sanitize_doc_comment(&self, desc: &str) -> String { // Sanitize description to prevent doctest failures let mut result = desc.to_string(); @@ -2216,7 +2534,7 @@ impl CodeGenerator { use crate::analysis::SchemaType; match item_type { - SchemaType::Primitive { rust_type } => { + SchemaType::Primitive { rust_type, .. } => { // The string here may be anything from `i64` / `String` to // `serde_json::Value` to `Vec` to // `BTreeMap`. Parse it as a syn::Type so we get @@ -2265,6 +2583,17 @@ impl CodeGenerator { "f32" | "f64" => return "Number".to_string(), "String" => return "String".to_string(), "serde_json::Value" => return "Value".to_string(), + // Q2 typed-scalar paths. Without these the fallback PascalCase + // pass over `bytes::Bytes` produces `BytesBytes(BytesBytes)`, + // which then can't compile because no `BytesBytes` type exists. + "bytes::Bytes" => return "Binary".to_string(), + "chrono::DateTime" => return "DateTime".to_string(), + "chrono::NaiveDate" => return "Date".to_string(), + "chrono::NaiveTime" => return "Time".to_string(), + "uuid::Uuid" => return "Uuid".to_string(), + "url::Url" => return "Url".to_string(), + "std::net::Ipv4Addr" => return "Ipv4".to_string(), + "std::net::Ipv6Addr" => return "Ipv6".to_string(), _ => {} } diff --git a/src/lib.rs b/src/lib.rs index 9e57dd3..d5c50af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod openapi; pub mod patterns; pub mod registry_generator; pub mod streaming; +pub mod type_mapping; pub mod test_helpers; @@ -21,5 +22,8 @@ pub use generator::{CodeGenerator, GeneratedFile, GenerationResult, GeneratorCon pub use http_config::{AuthConfig, HttpClientConfig, RetryConfig}; pub use http_error::{ApiError, ApiOpError, HttpError, HttpResult}; pub use openapi::{OpenApiSpec, Schema, SchemaType}; +pub use type_mapping::{ + DepRequirement, MappedType, TypeFeature, TypeMapper, TypeMappingConfig, UsedFeatures, +}; pub type Result = std::result::Result; diff --git a/src/snapshots/openapi_to_rust__test_helpers__content_union_structured.snap b/src/snapshots/openapi_to_rust__test_helpers__content_union_structured.snap index 25ea5f9..675e1cc 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__content_union_structured.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__content_union_structured.snap @@ -16,11 +16,10 @@ pub struct InputMessage { pub content: InputMessageContent, pub role: String, } -pub type InputMessageContentString = String; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum InputMessageContent { - InputMessageContentString(InputMessageContentString), + String(String), ContentBlockArray(ContentBlockArray), } ///Array variant in union diff --git a/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap b/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap index 896c9de..5df6a3d 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap @@ -53,10 +53,9 @@ pub struct RequestTextBlockCacheControl { pub struct RequestImageBlock { pub source: String, } -pub type CreateMessageParamsSystemString = String; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum CreateMessageParamsSystem { - CreateMessageParamsSystemString(CreateMessageParamsSystemString), + String(String), RequestTextBlockArray(RequestTextBlockArray), } diff --git a/src/snapshots/openapi_to_rust__test_helpers__inline_variant_naming.snap b/src/snapshots/openapi_to_rust__test_helpers__inline_variant_naming.snap index 3429c7b..1d6d011 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__inline_variant_naming.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__inline_variant_naming.snap @@ -14,11 +14,9 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum MessageContent { - MessageContentString(MessageContentString), + String(String), ContentBlockArray(ContentBlockArray), } -///Plain text content -pub type MessageContentString = String; ///Array variant in union pub type ContentBlockArray = Vec; #[derive(Debug, Clone, Deserialize, Serialize)] @@ -29,12 +27,8 @@ pub struct ContentBlock { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum ConfigValue { - ConfigValueString(ConfigValueString), - ConfigValueIntegerVariant1(ConfigValueIntegerVariant1), - ConfigValueBooleanVariant2(ConfigValueBooleanVariant2), - ConfigValueNumberVariant3(ConfigValueNumberVariant3), + String(String), + Integer(i64), + Boolean(bool), + Number(f64), } -pub type ConfigValueString = String; -pub type ConfigValueNumberVariant3 = f64; -pub type ConfigValueIntegerVariant1 = i64; -pub type ConfigValueBooleanVariant2 = bool; diff --git a/src/snapshots/openapi_to_rust__test_helpers__multi_array_variants.snap b/src/snapshots/openapi_to_rust__test_helpers__multi_array_variants.snap index 1aa74b6..c51326a 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__multi_array_variants.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__multi_array_variants.snap @@ -14,14 +14,13 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum MultiArrayContent { - MultiArrayContentString(MultiArrayContentString), + String(String), MultiArrayContentStringArray(MultiArrayContentStringArray), MultiArrayContentArray(MultiArrayContentArray), ItemArray(ItemArray), } ///Array variant in union pub type MultiArrayContentStringArray = Vec; -pub type MultiArrayContentString = String; ///Array variant in union pub type MultiArrayContentArray = Vec; ///Array variant in union diff --git a/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap b/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap index 99f1aa1..8ce20ea 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap @@ -30,7 +30,8 @@ pub struct NestedResponseMetadata { } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct NestedResponseAttributes { - /// Additional properties not explicitly defined in the schema + /// Additional properties matching the spec's + /// `additionalProperties` value schema. #[serde(flatten)] - pub additional_properties: std::collections::BTreeMap, + pub additional_properties: std::collections::BTreeMap, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__nested_union_array.snap b/src/snapshots/openapi_to_rust__test_helpers__nested_union_array.snap index 1a9e1a6..7a3f860 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nested_union_array.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nested_union_array.snap @@ -20,7 +20,7 @@ pub enum ComplexContent { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum ComplexContentItemUnion { - ArrayItemString(ArrayItemString), + String(String), Nested(NestedItem), } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -30,7 +30,6 @@ pub struct NestedItem { #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, } -pub type ArrayItemString = String; ///Array variant in union pub type ComplexContentArray = Vec; #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap b/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap index 751bf5c..6e7ad07 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap @@ -17,11 +17,10 @@ pub struct ConfigObject { pub cache_control: Option, pub display_settings: ConfigObjectDisplaySettings, } -pub type ConfigObjectDisplaySettingsString = String; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum ConfigObjectDisplaySettings { - ConfigObjectDisplaySettingsString(ConfigObjectDisplaySettingsString), + String(String), HeightBlock(HeightBlock), } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap b/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap index e8caf9d..5b3c3a8 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap @@ -15,9 +15,9 @@ use serde::{Deserialize, Serialize}; pub struct RequestToolResultBlock { #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, + ///Constraint: pattern=`^[a-zA-Z0-9_-]+$` pub tool_use_id: String, } -pub type RequestToolResultBlockContentString = String; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type")] pub enum RequestToolResultBlockContentItemUnion { @@ -37,7 +37,7 @@ pub type RequestToolResultBlockContentArray = Vec< #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum RequestToolResultBlockContent { - RequestToolResultBlockContentString(RequestToolResultBlockContentString), + String(String), RequestToolResultBlockContentArray(RequestToolResultBlockContentArray), } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -54,7 +54,7 @@ pub struct RequestImageBlockSource { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum MessageContent { - MessageContentString(MessageContentString), + String(String), MessageContentStringArray(MessageContentStringArray), MessageContentItemArray(MessageContentItemArray), } @@ -67,4 +67,3 @@ pub struct MessageContentItem { } ///Array variant in union pub type MessageContentStringArray = Vec; -pub type MessageContentString = String; diff --git a/src/test_helpers.rs b/src/test_helpers.rs index 58346e3..f1a8d0c 100644 --- a/src/test_helpers.rs +++ b/src/test_helpers.rs @@ -287,6 +287,7 @@ pub fn run_generation_test( auth_config: None, enable_registry: false, registry_only: false, + types: crate::type_mapping::TypeMappingConfig::default(), }; // Generate code @@ -321,6 +322,12 @@ pub fn run_generation_test( ("futures-util", r#""0.3""#), ("tokio", r#"{ version = "1.0", features = ["full"] }"#), ("tracing", r#""0.1""#), + // Q2 typed-scalar deps (default-on; harmless when unused). + ("chrono", r#"{ version = "0.4", features = ["serde"] }"#), + ("uuid", r#"{ version = "1", features = ["serde", "v4"] }"#), + ("url", r#"{ version = "2", features = ["serde"] }"#), + ("bytes", r#"{ version = "1", features = ["serde"] }"#), + ("base64", r#""0.22""#), ]; // Add extra dependencies diff --git a/src/type_mapping.rs b/src/type_mapping.rs new file mode 100644 index 0000000..16b69e6 --- /dev/null +++ b/src/type_mapping.rs @@ -0,0 +1,1062 @@ +//! Centralized OpenAPI type → Rust type mapping. +//! +//! [`TypeMapper`] is the single chokepoint for every `(openapi_type, +//! format)` → Rust-type decision. Q2.0 introduced the chokepoint with +//! pass-through behavior; Q2 (quq) flips the defaults so common string +//! formats (`date-time`, `uuid`, `uri`, …) become typed Rust scalars +//! out of the box. +//! +//! # Design +//! - Per-format **strategy enums** (e.g. [`DateStrategy`]) drive the +//! mapping. Defaults are opt-out: typed by default, set the +//! strategy to `String` to recover plain `String`. +//! - [`MappedType`] carries the Rust type **plus** an optional +//! `#[serde(with = "...")]` codec hint. Codec hints flow through +//! [`SchemaType::Primitive`](crate::analysis::SchemaType::Primitive) +//! to the field-emission site in `generator.rs`, which wraps them +//! in a `#[serde(with = …)]` attribute. +//! - [`UsedFeatures`] tracks which optional crates the mapper +//! actually emitted references to. Q2.8 will read this after +//! generation and write a `REQUIRED_DEPS.toml`. +//! +//! # Conservative mode +//! Pass `TypeMappingConfig::conservative()` (CLI: `--types-conservative`) +//! to recover pre-Q2 behavior — every format renders as `String`. Useful +//! for bisecting regressions caused by typed-scalar adoption. + +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::openapi::{SchemaDetails, SchemaType as OpenApiSchemaType}; + +/// Result of mapping an OpenAPI `(type, format)` pair to a Rust type. +#[derive(Debug, Clone)] +pub struct MappedType { + /// The Rust type as a string, e.g. `"String"`, + /// `"chrono::DateTime"`. + pub rust_type: String, + /// Optional `#[serde(with = "...")]` codec path. The generator + /// wraps this in a `with = ""` field attribute. + pub serde_with: Option, + /// Optional crate this mapping introduced. Tracked in + /// [`UsedFeatures`] for the dep advisory (Q2.8). + pub feature: Option, +} + +impl MappedType { + /// Construct a plain mapping with no codec and no external crate. + pub fn plain(rust_type: impl Into) -> Self { + Self { + rust_type: rust_type.into(), + serde_with: None, + feature: None, + } + } + + /// Plain mapping that records a feature crate (e.g. for types like + /// `std::net::Ipv4Addr` we don't need a codec but we don't need a + /// crate either — this helper is for crates that derive `serde` + /// directly on the type). + pub fn with_feature(rust_type: impl Into, feature: TypeFeature) -> Self { + Self { + rust_type: rust_type.into(), + serde_with: None, + feature: Some(feature), + } + } + + /// Mapping that requires a `#[serde(with = ...)]` codec. + pub fn with_codec( + rust_type: impl Into, + codec_path: impl Into, + feature: TypeFeature, + ) -> Self { + Self { + rust_type: rust_type.into(), + serde_with: Some(codec_path.into()), + feature: Some(feature), + } + } +} + +/// Identifies an optional crate a mapping introduced. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TypeFeature { + Chrono, + Time, + Iso8601, + Uuid, + Bytes, + Base64, + Url, + EmailAddress, +} + +impl TypeFeature { + /// Canonical dependency line for this feature. Q2.8 uses this to + /// emit `REQUIRED_DEPS.toml` next to the generated code so users + /// know exactly which crates to add to their Cargo.toml. + pub fn dep_requirement(self) -> DepRequirement { + match self { + Self::Chrono => DepRequirement::new("chrono", "0.4").with_features(&["serde"]), + Self::Time => DepRequirement::new("time", "0.3").with_features(&["serde"]), + Self::Iso8601 => DepRequirement::new("iso8601", "0.6"), + Self::Uuid => DepRequirement::new("uuid", "1").with_features(&["serde", "v4"]), + Self::Bytes => DepRequirement::new("bytes", "1").with_features(&["serde"]), + Self::Base64 => DepRequirement::new("base64", "0.22"), + Self::Url => DepRequirement::new("url", "2").with_features(&["serde"]), + Self::EmailAddress => DepRequirement::new("email_address", "0.2"), + } + } +} + +/// One crate the generated code needs in its `Cargo.toml`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepRequirement { + pub crate_name: &'static str, + pub version: &'static str, + pub features: Vec<&'static str>, +} + +impl DepRequirement { + pub fn new(crate_name: &'static str, version: &'static str) -> Self { + Self { + crate_name, + version, + features: Vec::new(), + } + } + + pub fn with_features(mut self, features: &[&'static str]) -> Self { + self.features = features.to_vec(); + self + } + + /// Render as a single TOML `[dependencies]` line. Picks the + /// most compact form that still expresses the required features. + pub fn to_toml_line(&self) -> String { + if self.features.is_empty() { + format!("{} = \"{}\"", self.crate_name, self.version) + } else { + let feats = self + .features + .iter() + .map(|f| format!("\"{f}\"")) + .collect::>() + .join(", "); + format!( + "{} = {{ version = \"{}\", features = [{}] }}", + self.crate_name, self.version, feats + ) + } + } +} + +/// Render `REQUIRED_DEPS.toml` content from a sorted set of +/// requirements. Returns `None` when the input is empty so the +/// caller can skip writing the file (no clutter when no optional +/// crates were used). +pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option { + if deps.is_empty() { + return None; + } + let mut out = String::new(); + out.push_str( + "# Generated by openapi-to-rust.\n\ + # These crates are required by the typed-scalar formats used\n\ + # in your OpenAPI spec. Copy these lines into the [dependencies]\n\ + # section of your consuming crate's Cargo.toml.\n\ + #\n\ + # To opt out of typed scalars (and avoid these deps), set\n\ + # the relevant strategies to \"string\" in [generator.types],\n\ + # or pass --types-conservative on the CLI.\n\ + \n\ + [dependencies]\n", + ); + for dep in deps { + out.push_str(&dep.to_toml_line()); + out.push('\n'); + } + Some(out) +} + +/// Snapshot a `UsedFeatures` set as a sorted, de-duplicated list of +/// `DepRequirement`s. Sorting by crate name keeps the emitted file +/// deterministic so it can be checked in or diffed. +pub fn collect_dep_requirements(used: &UsedFeatures) -> Vec { + let mut deps: Vec = used.iter().map(|f| f.dep_requirement()).collect(); + deps.sort_by_key(|d| d.crate_name); + deps.dedup_by_key(|d| d.crate_name); + deps +} + +/// Tracks which optional crates the generator emitted code for. +#[derive(Debug, Default, Clone)] +pub struct UsedFeatures { + set: BTreeSet, +} + +impl UsedFeatures { + pub fn insert(&mut self, feature: TypeFeature) { + self.set.insert(feature); + } + + pub fn contains(&self, feature: TypeFeature) -> bool { + self.set.contains(&feature) + } + + pub fn iter(&self) -> impl Iterator { + self.set.iter() + } + + pub fn is_empty(&self) -> bool { + self.set.is_empty() + } +} + +// ===================================================================== +// Strategy enums +// ===================================================================== + +/// Strategy for `format: date-time | date | time`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DateStrategy { + /// Plain `String`. Pre-Q2 behavior; pick this to opt out. + String, + /// `chrono::DateTime` / `NaiveDate` / `NaiveTime` (default). + #[default] + Chrono, + /// `time::OffsetDateTime` / `Date` / `Time`. + Time, +} + +/// Strategy for `format: duration` (ISO 8601 durations). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DurationStrategy { + // Off by default — `format: duration` is ISO 8601 (e.g. + // "PT1H30M") but `chrono::Duration`'s native serde encodes + // seconds. Round-tripping requires a custom parser that we'll + // land in a follow-up; for now `duration` stays String so + // default-on doesn't break specs that emit ISO 8601 strings + // the chrono codec couldn't decode. + #[default] + String, + /// `chrono::Duration`. Round-trips ISO 8601 durations via a + /// small custom serde module emitted into the generated crate. + Chrono, + /// `iso8601::Duration` from the `iso8601` crate. + Iso8601, +} + +/// Strategy for `format: uuid` (or normalized aliases). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum UuidStrategy { + String, + /// `uuid::Uuid` (default). + #[default] + Uuid, +} + +/// Strategy for `format: byte` (base64-encoded binary on the wire). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ByteStrategy { + String, + /// `Vec` round-tripped via an inlined `base64_serde` module + /// (default). + #[default] + Base64, + /// `Vec` with no codec (caller responsible for encoding). + VecU8, +} + +/// Strategy for `format: binary` (raw octets). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BinaryStrategy { + String, + /// `bytes::Bytes` (default). + #[default] + Bytes, + VecU8, +} + +/// Strategy for `format: ipv4 | ipv6`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum IpStrategy { + String, + /// `std::net::Ipv4Addr` / `Ipv6Addr` (default; pure std, no deps). + #[default] + Std, +} + +/// Strategy for `format: uri | url`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum UriStrategy { + String, + /// `url::Url` (default). + #[default] + Url, +} + +/// Strategy for `format: email`. +/// +/// Email is **off by default** — the `email_address` crate is more +/// opinionated than the wire ever guarantees, and most APIs treat +/// emails as opaque strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EmailStrategy { + #[default] + String, + EmailAddress, +} + +// ===================================================================== +// Top-level config +// ===================================================================== + +/// Configuration for [`TypeMapper`]. Mirrors the `[generator.types]` +/// TOML section. Defaults flip on every common typed scalar; opt out +/// per format by setting the strategy to `string` in TOML. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, rename_all = "snake_case")] +pub struct TypeMappingConfig { + pub date_time: DateStrategy, + pub date: DateStrategy, + pub time: DateStrategy, + pub duration: DurationStrategy, + pub uuid: UuidStrategy, + pub byte: ByteStrategy, + pub binary: BinaryStrategy, + pub ipv4: IpStrategy, + pub ipv6: IpStrategy, + pub uri: UriStrategy, + pub email: EmailStrategy, + + /// Q2.1: honor `format: uint32` / `uint64` integer formats and + /// map them to `u32` / `u64` respectively. Default `true` (cheap, + /// no extra crate). Set `false` to revert to the pre-Q2.1 + /// behavior where unsigned formats degraded to `i64`. + #[serde(default = "default_true")] + pub unsigned: bool, + + /// Q2.2: user-extensible format aliases applied before standard + /// format dispatch (e.g. `"uuid4" -> "uuid"`, + /// `"unix-time" -> "int64"`). Built-in defaults are merged with + /// user-supplied entries; user entries win on collision. + #[serde(default)] + pub format_aliases: BTreeMap, + + /// Object/array shape toggles. Filled in by Q2.3, Q2.5, Q2.7. + pub shape: Option, + + /// Constraint annotation mode. Filled in by Q2.4. + pub constraints: Option, + + /// Vendor-extension toggles for enums. Filled in by Q2.6. + pub enums: Option, +} + +fn default_true() -> bool { + true +} + +impl Default for TypeMappingConfig { + fn default() -> Self { + Self { + date_time: DateStrategy::default(), + date: DateStrategy::default(), + time: DateStrategy::default(), + duration: DurationStrategy::default(), + uuid: UuidStrategy::default(), + byte: ByteStrategy::default(), + binary: BinaryStrategy::default(), + ipv4: IpStrategy::default(), + ipv6: IpStrategy::default(), + uri: UriStrategy::default(), + email: EmailStrategy::default(), + unsigned: true, + format_aliases: BTreeMap::new(), + shape: None, + constraints: None, + enums: None, + } + } +} + +/// Built-in format aliases applied before user-supplied +/// [`TypeMappingConfig::format_aliases`]. These normalize common +/// vendor-isms found in real-world specs so the standard format +/// dispatch in [`TypeMapper::string_format`] / +/// [`TypeMapper::integer_format`] sees canonical names. +fn builtin_format_aliases() -> &'static [(&'static str, &'static str)] { + &[ + ("uuid4", "uuid"), + ("uuid_v4", "uuid"), + ("UUID", "uuid"), + ("unix-time", "int64"), + ("unix_time", "int64"), + ("unixtime", "int64"), + ("timestamp", "int64"), + ] +} + +impl TypeMappingConfig { + /// Q2.4: constraint-doc emission mode. Defaults to + /// [`ConstraintMode::Doc`] when the + /// `[generator.types.constraints]` block is absent or its + /// `mode` field is unset. + pub fn constraint_mode(&self) -> ConstraintMode { + self.constraints + .as_ref() + .and_then(|c| c.mode) + .unwrap_or_default() + } + + /// Q2.6: should `x-enum-varnames` override the heuristic + /// PascalCase variant naming? Default true. + pub fn x_enum_varnames_enabled(&self) -> bool { + self.enums + .as_ref() + .and_then(|e| e.x_enum_varnames) + .unwrap_or(true) + } + + /// Q2.6: should `x-enum-descriptions` emit per-variant doc + /// comments? Default true. + pub fn x_enum_descriptions_enabled(&self) -> bool { + self.enums + .as_ref() + .and_then(|e| e.x_enum_descriptions) + .unwrap_or(true) + } + + /// Pre-Q2 behavior — every format renders as `String` and + /// integer formats degrade to `i64`. Users opt in via + /// `--types-conservative` when bisecting regressions introduced + /// by typed-scalar adoption. + pub fn conservative() -> Self { + Self { + date_time: DateStrategy::String, + date: DateStrategy::String, + time: DateStrategy::String, + duration: DurationStrategy::String, + uuid: UuidStrategy::String, + byte: ByteStrategy::String, + binary: BinaryStrategy::String, + ipv4: IpStrategy::String, + ipv6: IpStrategy::String, + uri: UriStrategy::String, + email: EmailStrategy::String, + unsigned: false, + format_aliases: BTreeMap::new(), + shape: None, + constraints: None, + enums: None, + } + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default, rename_all = "snake_case")] +pub struct TypeShapeConfig { + pub additional_properties_typed: Option, + pub unique_items_to_set: Option, + pub primitive_unions: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default, rename_all = "snake_case")] +pub struct TypeConstraintsConfig { + /// Q2.4 constraint annotation mode. Defaults to `Doc` when the + /// `[generator.types.constraints]` block is absent (see + /// [`TypeMapper::config_constraint_mode`]). + pub mode: Option, +} + +/// Q2.4 — what to emit for OpenAPI constraint keywords +/// (`minimum`/`maximum`/`minLength`/`maxLength`/`pattern`/etc.). +/// +/// **No client-side validation.** Constraints belong to the wire +/// contract; the server is the source of truth. The generator +/// surfaces them only as doc-comments so callers see the rules +/// without the SDK duplicating server logic and going brittle +/// when the rules drift. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConstraintMode { + /// Drop constraints entirely (pre-Q2.4 behavior). + Off, + /// Emit `/// Constraint: ...` doc comments on each field. + /// Cheap, no extra crate dependency. Default. + #[default] + Doc, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default, rename_all = "snake_case")] +pub struct TypeEnumsConfig { + pub x_enum_varnames: Option, + pub x_enum_descriptions: Option, +} + +// ===================================================================== +// TypeMapper +// ===================================================================== + +pub struct TypeMapper { + config: TypeMappingConfig, + used: RefCell, +} + +impl Default for TypeMapper { + fn default() -> Self { + Self::new(TypeMappingConfig::default()) + } +} + +impl TypeMapper { + pub fn new(config: TypeMappingConfig) -> Self { + Self { + config, + used: RefCell::new(UsedFeatures::default()), + } + } + + /// Snapshot of crates this mapper has emitted references to. + /// Read after generation by Q2.8 to write `REQUIRED_DEPS.toml`. + pub fn used_features(&self) -> UsedFeatures { + self.used.borrow().clone() + } + + /// Borrow the underlying type-mapping config — useful for + /// non-format-mapping toggles (`shape`, `enums`, `constraints`) + /// that other modules need to inspect. + pub fn config(&self) -> &TypeMappingConfig { + &self.config + } + + /// Q2.7 helper: should `anyOf` of primitives become an untagged + /// enum with primitive variant types directly (true), or fall + /// back to the pre-Q2.7 type-alias-per-variant shape (false)? + /// Default: true. + pub fn config_shape_primitive_unions(&self) -> Option { + self.config.shape.as_ref().and_then(|s| s.primitive_unions) + } + + /// Q2.3 helper: should `additionalProperties: ` produce + /// `BTreeMap` (true) or degrade to `BTreeMap` (false)? Default: true. + pub fn config_shape_additional_properties_typed(&self) -> Option { + self.config + .shape + .as_ref() + .and_then(|s| s.additional_properties_typed) + } + + /// Q2.4 helper: which constraint-annotation mode is active? + /// Defaults to [`ConstraintMode::Doc`] when the + /// `[generator.types.constraints]` block is absent or its `mode` + /// field is unset. + pub fn config_constraint_mode(&self) -> ConstraintMode { + self.config + .constraints + .as_ref() + .and_then(|c| c.mode) + .unwrap_or_default() + } + + fn record(&self, feature: TypeFeature) { + self.used.borrow_mut().insert(feature); + } + + /// Map `string` + optional `format` → typed Rust scalar. + /// + /// Routing: + /// 1. Apply user-provided + built-in `format_aliases`. + /// 2. Dispatch on the normalized format. + /// 3. Honor each format's strategy in `self.config`. + /// 4. Record any introduced crate in `used_features`. + pub fn string_format(&self, format: Option<&str>) -> MappedType { + let normalized = self.normalize_format(format); + match normalized.as_deref() { + Some("date-time") => self.map_date_time(self.config.date_time), + Some("date") => self.map_date(self.config.date), + Some("time") => self.map_time(self.config.time), + Some("duration") => self.map_duration(self.config.duration), + Some("uuid") => self.map_uuid(self.config.uuid), + Some("byte") => self.map_byte(self.config.byte), + Some("binary") => self.map_binary(self.config.binary), + Some("ipv4") => self.map_ipv4(self.config.ipv4), + Some("ipv6") => self.map_ipv6(self.config.ipv6), + Some("uri") | Some("url") => self.map_uri(self.config.uri), + Some("email") => self.map_email(self.config.email), + // Unknown formats (hostname, password, idn-email, etc.) + // and the no-format case fall through to plain String. + _ => MappedType::plain("String"), + } + } + + /// Apply user + built-in format aliases (in that order — user + /// entries win on collision). Built-ins normalize common + /// vendor-isms like `uuid4` → `uuid` and `unix-time` → `int64` + /// so the standard format dispatch below sees canonical names. + fn normalize_format(&self, format: Option<&str>) -> Option { + let raw = format?; + if let Some(target) = self.config.format_aliases.get(raw) { + return Some(target.clone()); + } + for (from, to) in builtin_format_aliases() { + if *from == raw { + return Some((*to).to_string()); + } + } + Some(raw.to_string()) + } + + fn map_date_time(&self, strat: DateStrategy) -> MappedType { + match strat { + DateStrategy::String => MappedType::plain("String"), + DateStrategy::Chrono => { + self.record(TypeFeature::Chrono); + // chrono::DateTime with the `serde` feature + // serializes as RFC 3339 by default and parses both + // `Z` and `+HH:MM` offsets on input. No `with` + // attribute required. + MappedType::with_feature("chrono::DateTime", TypeFeature::Chrono) + } + DateStrategy::Time => { + self.record(TypeFeature::Time); + MappedType::with_codec( + "time::OffsetDateTime", + "time::serde::rfc3339", + TypeFeature::Time, + ) + } + } + } + + fn map_date(&self, strat: DateStrategy) -> MappedType { + match strat { + DateStrategy::String => MappedType::plain("String"), + DateStrategy::Chrono => { + self.record(TypeFeature::Chrono); + // chrono derives serde via the `serde` feature; no + // codec needed for NaiveDate (ISO 8601 by default). + MappedType::with_feature("chrono::NaiveDate", TypeFeature::Chrono) + } + DateStrategy::Time => { + self.record(TypeFeature::Time); + MappedType::with_codec("time::Date", "time::serde::iso8601", TypeFeature::Time) + } + } + } + + fn map_time(&self, strat: DateStrategy) -> MappedType { + match strat { + DateStrategy::String => MappedType::plain("String"), + DateStrategy::Chrono => { + self.record(TypeFeature::Chrono); + MappedType::with_feature("chrono::NaiveTime", TypeFeature::Chrono) + } + DateStrategy::Time => { + self.record(TypeFeature::Time); + MappedType::with_codec("time::Time", "time::serde::iso8601", TypeFeature::Time) + } + } + } + + fn map_duration(&self, strat: DurationStrategy) -> MappedType { + match strat { + DurationStrategy::String => MappedType::plain("String"), + DurationStrategy::Chrono => { + // Placeholder: chrono::Duration's native serde + // encodes seconds (not ISO 8601). A follow-up will + // emit an iso8601_duration_serde helper module and + // wire it via with_codec; for now downgrade to the + // String mapping so this strategy is safe to enable + // even before the helper exists. + MappedType::plain("String") + } + DurationStrategy::Iso8601 => { + self.record(TypeFeature::Iso8601); + MappedType::with_feature("iso8601::Duration", TypeFeature::Iso8601) + } + } + } + + fn map_uuid(&self, strat: UuidStrategy) -> MappedType { + match strat { + UuidStrategy::String => MappedType::plain("String"), + UuidStrategy::Uuid => { + self.record(TypeFeature::Uuid); + MappedType::with_feature("uuid::Uuid", TypeFeature::Uuid) + } + } + } + + fn map_byte(&self, strat: ByteStrategy) -> MappedType { + match strat { + ByteStrategy::String => MappedType::plain("String"), + ByteStrategy::VecU8 => MappedType::plain("Vec"), + ByteStrategy::Base64 => { + self.record(TypeFeature::Base64); + // Path is resolved relative to the generated + // module; the helper module is emitted as + // `base64_serde` at the top of `types.rs`. + MappedType::with_codec("Vec", "base64_serde", TypeFeature::Base64) + } + } + } + + fn map_binary(&self, strat: BinaryStrategy) -> MappedType { + match strat { + BinaryStrategy::String => MappedType::plain("String"), + BinaryStrategy::VecU8 => MappedType::plain("Vec"), + BinaryStrategy::Bytes => { + self.record(TypeFeature::Bytes); + MappedType::with_feature("bytes::Bytes", TypeFeature::Bytes) + } + } + } + + fn map_ipv4(&self, strat: IpStrategy) -> MappedType { + match strat { + IpStrategy::String => MappedType::plain("String"), + IpStrategy::Std => MappedType::plain("std::net::Ipv4Addr"), + } + } + + fn map_ipv6(&self, strat: IpStrategy) -> MappedType { + match strat { + IpStrategy::String => MappedType::plain("String"), + IpStrategy::Std => MappedType::plain("std::net::Ipv6Addr"), + } + } + + fn map_uri(&self, strat: UriStrategy) -> MappedType { + match strat { + UriStrategy::String => MappedType::plain("String"), + UriStrategy::Url => { + self.record(TypeFeature::Url); + MappedType::with_feature("url::Url", TypeFeature::Url) + } + } + } + + fn map_email(&self, strat: EmailStrategy) -> MappedType { + match strat { + EmailStrategy::String => MappedType::plain("String"), + EmailStrategy::EmailAddress => { + self.record(TypeFeature::EmailAddress); + MappedType::with_feature("email_address::EmailAddress", TypeFeature::EmailAddress) + } + } + } + + /// Map `integer` + optional `format` → Rust type. + /// + /// Q2.1: honors `uint32` / `uint64` (and a few vendor variants + /// like `uint`) when `config.unsigned` is true (default). + /// Setting `unsigned = false` reverts to the pre-Q2.1 behavior + /// where unsigned formats degrade to `i64`. + pub fn integer_format(&self, format: Option<&str>) -> MappedType { + let normalized = self.normalize_format(format); + match normalized.as_deref() { + Some("int32") => MappedType::plain("i32"), + Some("int64") => MappedType::plain("i64"), + Some("uint32") if self.config.unsigned => MappedType::plain("u32"), + Some("uint64") if self.config.unsigned => MappedType::plain("u64"), + // OAS-adjacent specs sometimes use bare `uint` — treat + // it as 64-bit unsigned to match the broadest intended + // domain. + Some("uint") if self.config.unsigned => MappedType::plain("u64"), + _ => MappedType::plain("i64"), + } + } + + pub fn number_format(&self, format: Option<&str>) -> MappedType { + let normalized = self.normalize_format(format); + match normalized.as_deref() { + Some("float") => MappedType::plain("f32"), + Some("double") => MappedType::plain("f64"), + _ => MappedType::plain("f64"), + } + } + + pub fn boolean(&self) -> MappedType { + MappedType::plain("bool") + } + + pub fn untyped_array(&self) -> MappedType { + MappedType::plain("Vec") + } + + pub fn dynamic_json(&self) -> MappedType { + MappedType::plain("serde_json::Value") + } + + pub fn null_unit(&self) -> MappedType { + MappedType::plain("()") + } + + /// One-shot dispatch from `(OpenApiSchemaType, &SchemaDetails)`. + pub fn map(&self, ty: OpenApiSchemaType, details: &SchemaDetails) -> MappedType { + let format = details.format.as_deref(); + match ty { + OpenApiSchemaType::String => self.string_format(format), + OpenApiSchemaType::Integer => self.integer_format(format), + OpenApiSchemaType::Number => self.number_format(format), + OpenApiSchemaType::Boolean => self.boolean(), + OpenApiSchemaType::Array => self.untyped_array(), + OpenApiSchemaType::Object => self.dynamic_json(), + OpenApiSchemaType::Null => self.null_unit(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn details_with_format(format: Option<&str>) -> SchemaDetails { + SchemaDetails { + format: format.map(str::to_string), + ..Default::default() + } + } + + #[test] + fn default_mapper_emits_typed_scalars_for_common_formats() { + let m = TypeMapper::default(); + assert_eq!( + m.string_format(Some("date-time")).rust_type, + "chrono::DateTime" + ); + assert_eq!(m.string_format(Some("date")).rust_type, "chrono::NaiveDate"); + assert_eq!(m.string_format(Some("uuid")).rust_type, "uuid::Uuid"); + assert_eq!(m.string_format(Some("uri")).rust_type, "url::Url"); + assert_eq!( + m.string_format(Some("ipv4")).rust_type, + "std::net::Ipv4Addr" + ); + assert_eq!(m.string_format(Some("byte")).rust_type, "Vec"); + assert_eq!(m.string_format(Some("binary")).rust_type, "bytes::Bytes"); + } + + #[test] + fn date_time_uses_default_chrono_serde() { + // chrono::DateTime with the `serde` feature serializes + // as RFC 3339 by default — no `with = ...` codec required. + let m = TypeMapper::default(); + let mt = m.string_format(Some("date-time")); + assert_eq!(mt.rust_type, "chrono::DateTime"); + assert!(mt.serde_with.is_none()); + assert_eq!(mt.feature, Some(TypeFeature::Chrono)); + } + + #[test] + fn byte_emits_base64_codec() { + let m = TypeMapper::default(); + let mt = m.string_format(Some("byte")); + assert_eq!(mt.rust_type, "Vec"); + assert_eq!(mt.serde_with.as_deref(), Some("base64_serde")); + assert_eq!(mt.feature, Some(TypeFeature::Base64)); + } + + #[test] + fn conservative_config_collapses_everything_to_string() { + let m = TypeMapper::new(TypeMappingConfig::conservative()); + for fmt in [ + Some("date-time"), + Some("uuid"), + Some("uri"), + Some("byte"), + Some("binary"), + Some("ipv4"), + Some("ipv6"), + Some("date"), + None, + ] { + let mt = m.string_format(fmt); + assert_eq!(mt.rust_type, "String", "format = {fmt:?}"); + assert!(mt.serde_with.is_none(), "format = {fmt:?}"); + } + } + + #[test] + fn unknown_formats_fall_through_to_string() { + let m = TypeMapper::default(); + for fmt in [Some("hostname"), Some("password"), Some("idn-email")] { + assert_eq!(m.string_format(fmt).rust_type, "String"); + } + } + + #[test] + fn integer_formats_match_pre_refactor_behavior() { + let m = TypeMapper::default(); + assert_eq!(m.integer_format(Some("int32")).rust_type, "i32"); + assert_eq!(m.integer_format(Some("int64")).rust_type, "i64"); + assert_eq!(m.integer_format(None).rust_type, "i64"); + } + + #[test] + fn integer_formats_default_handles_unsigned_q21() { + let m = TypeMapper::default(); + assert_eq!(m.integer_format(Some("uint32")).rust_type, "u32"); + assert_eq!(m.integer_format(Some("uint64")).rust_type, "u64"); + // Non-standard `uint` falls into the broader uint64 bucket. + assert_eq!(m.integer_format(Some("uint")).rust_type, "u64"); + } + + #[test] + fn unsigned_off_degrades_uint_to_i64() { + let mut cfg = TypeMappingConfig::default(); + cfg.unsigned = false; + let m = TypeMapper::new(cfg); + assert_eq!(m.integer_format(Some("uint32")).rust_type, "i64"); + assert_eq!(m.integer_format(Some("uint64")).rust_type, "i64"); + } + + #[test] + fn conservative_disables_unsigned() { + let m = TypeMapper::new(TypeMappingConfig::conservative()); + assert_eq!(m.integer_format(Some("uint64")).rust_type, "i64"); + } + + #[test] + fn builtin_aliases_normalize_uuid_variants_to_uuid() { + let m = TypeMapper::default(); + for fmt in ["uuid4", "uuid_v4", "UUID"] { + let mt = m.string_format(Some(fmt)); + assert_eq!(mt.rust_type, "uuid::Uuid", "format = {fmt}"); + } + } + + #[test] + fn builtin_aliases_normalize_unix_time_to_int64() { + let m = TypeMapper::default(); + for fmt in ["unix-time", "unix_time", "unixtime", "timestamp"] { + let mt = m.integer_format(Some(fmt)); + assert_eq!(mt.rust_type, "i64", "format = {fmt}"); + } + } + + #[test] + fn user_alias_overrides_builtin() { + let mut cfg = TypeMappingConfig::default(); + // User wants `uuid4` to mean plain string instead of uuid. + cfg.format_aliases + .insert("uuid4".to_string(), "hostname".to_string()); + let m = TypeMapper::new(cfg); + // hostname is unmapped → falls through to String. + assert_eq!(m.string_format(Some("uuid4")).rust_type, "String"); + } + + #[test] + fn used_features_records_referenced_crates() { + let m = TypeMapper::default(); + let _ = m.string_format(Some("date-time")); + let _ = m.string_format(Some("uuid")); + let used = m.used_features(); + assert!(used.contains(TypeFeature::Chrono)); + assert!(used.contains(TypeFeature::Uuid)); + assert!(!used.contains(TypeFeature::Bytes)); + } + + #[test] + fn format_alias_normalizes_before_dispatch() { + let mut cfg = TypeMappingConfig::default(); + cfg.format_aliases + .insert("uuid4".to_string(), "uuid".to_string()); + let m = TypeMapper::new(cfg); + assert_eq!(m.string_format(Some("uuid4")).rust_type, "uuid::Uuid"); + } + + #[test] + fn conservative_helper_round_trips() { + let cfg = TypeMappingConfig::conservative(); + assert!(matches!(cfg.date_time, DateStrategy::String)); + assert!(matches!(cfg.uuid, UuidStrategy::String)); + } + + #[test] + fn dep_requirement_renders_features_list() { + let dep = TypeFeature::Chrono.dep_requirement(); + assert_eq!(dep.crate_name, "chrono"); + assert_eq!(dep.features, vec!["serde"]); + assert_eq!( + dep.to_toml_line(), + r#"chrono = { version = "0.4", features = ["serde"] }"# + ); + } + + #[test] + fn dep_requirement_omits_features_when_none() { + let dep = TypeFeature::Base64.dep_requirement(); + assert_eq!(dep.to_toml_line(), r#"base64 = "0.22""#); + } + + #[test] + fn collect_dep_requirements_is_sorted_and_unique() { + let mut used = UsedFeatures::default(); + used.insert(TypeFeature::Url); + used.insert(TypeFeature::Chrono); + used.insert(TypeFeature::Chrono); // duplicate + used.insert(TypeFeature::Uuid); + let deps = collect_dep_requirements(&used); + assert_eq!( + deps.iter().map(|d| d.crate_name).collect::>(), + vec!["chrono", "url", "uuid"] + ); + } + + #[test] + fn render_required_deps_toml_is_none_when_empty() { + let deps: Vec = Vec::new(); + assert!(render_required_deps_toml(&deps).is_none()); + } + + #[test] + fn render_required_deps_toml_includes_dependencies_block() { + let deps = vec![ + TypeFeature::Chrono.dep_requirement(), + TypeFeature::Uuid.dep_requirement(), + ]; + let toml = render_required_deps_toml(&deps).expect("non-empty"); + assert!(toml.contains("[dependencies]")); + assert!(toml.contains("chrono = ")); + assert!(toml.contains("uuid = ")); + assert!(toml.contains("# Generated by openapi-to-rust")); + } + + #[test] + fn map_dispatches_through_helpers() { + let m = TypeMapper::default(); + assert_eq!( + m.map( + OpenApiSchemaType::String, + &details_with_format(Some("uuid")) + ) + .rust_type, + "uuid::Uuid" + ); + assert_eq!( + m.map( + OpenApiSchemaType::Integer, + &details_with_format(Some("int32")) + ) + .rust_type, + "i32" + ); + } +} diff --git a/tests/additional_properties_typed_test.rs b/tests/additional_properties_typed_test.rs new file mode 100644 index 0000000..3008cb3 --- /dev/null +++ b/tests/additional_properties_typed_test.rs @@ -0,0 +1,169 @@ +//! Q2.3: typed `BTreeMap` from +//! `additionalProperties: ` (default on; opt out via +//! `[generator.types.shape] additional_properties_typed = false`). + +use openapi_to_rust::{ + CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, + type_mapping::TypeShapeConfig, +}; +use serde_json::json; + +fn ap_spec(value_schema: serde_json::Value) -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "ap", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Bag": { + "type": "object", + "additionalProperties": value_schema + } + } + } + }) +} + +fn generate(spec: serde_json::Value, mapper: TypeMapper) -> String { + let mut analyzer = SchemaAnalyzer::with_type_mapper(spec, mapper).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + ..Default::default() + }; + CodeGenerator::new(cfg) + .generate(&mut analysis) + .expect("generate") +} + +#[test] +fn ap_string_schema_default_emits_typed_btreemap() { + let code = generate( + ap_spec(json!({ "type": "string" })), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub additional_properties: std::collections::BTreeMap"), + "additionalProperties: should produce BTreeMap. Code:\n{code}" + ); +} + +#[test] +fn ap_integer_schema_default_emits_typed_btreemap() { + let code = generate( + ap_spec(json!({ "type": "integer", "format": "int32" })), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub additional_properties: std::collections::BTreeMap"), + "additionalProperties with int32 should produce BTreeMap. Code:\n{code}" + ); +} + +#[test] +fn ap_typed_default_picks_up_format_typed_scalars() { + // The value-type analysis should respect TypeMapper format + // strategies, so additionalProperties: { format: uuid } yields + // BTreeMap. + let code = generate( + ap_spec(json!({ "type": "string", "format": "uuid" })), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub additional_properties: std::collections::BTreeMap"), + "additionalProperties with format: uuid should produce BTreeMap. Code:\n{code}" + ); +} + +#[test] +fn ap_boolean_true_remains_untyped() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "ap", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Bag": { + "type": "object", + "additionalProperties": true + } + } + } + }); + let code = generate(spec, TypeMapper::new(TypeMappingConfig::default())); + assert!( + code.contains( + "pub additional_properties: std::collections::BTreeMap" + ), + "additionalProperties: true should still produce BTreeMap. Code:\n{code}" + ); +} + +#[test] +fn ap_boolean_false_emits_no_field() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "ap", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Bag": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"], + "additionalProperties": false + } + } + } + }); + let code = generate(spec, TypeMapper::new(TypeMappingConfig::default())); + assert!( + !code.contains("pub additional_properties:"), + "additionalProperties: false must not emit a field. Code:\n{code}" + ); +} + +#[test] +fn ap_typed_off_falls_back_to_untyped() { + let mut cfg = TypeMappingConfig::default(); + cfg.shape = Some(TypeShapeConfig { + additional_properties_typed: Some(false), + ..Default::default() + }); + let code = generate(ap_spec(json!({ "type": "string" })), TypeMapper::new(cfg)); + assert!( + code.contains( + "pub additional_properties: std::collections::BTreeMap" + ), + "additional_properties_typed = false should degrade to serde_json::Value. Code:\n{code}" + ); +} + +#[test] +fn ap_schema_ref_emits_btreemap_of_named_type() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "ap", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Item": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } } + }, + "Bag": { + "type": "object", + "additionalProperties": { "$ref": "#/components/schemas/Item" } + } + } + } + }); + let code = generate(spec, TypeMapper::new(TypeMappingConfig::default())); + assert!( + code.contains("pub additional_properties: std::collections::BTreeMap"), + "additionalProperties: $ref should produce BTreeMap. Code:\n{code}" + ); +} diff --git a/tests/constraint_doc_test.rs b/tests/constraint_doc_test.rs new file mode 100644 index 0000000..a5ef3d1 --- /dev/null +++ b/tests/constraint_doc_test.rs @@ -0,0 +1,213 @@ +//! Q2.4 — OpenAPI constraint annotations as `/// Constraint: …` +//! doc comments. **No client-side validation**: the generator never +//! emits `#[validate(...)]` attributes or pulls in the `validator` +//! crate. Constraints belong on the wire contract; the server is +//! the source of truth. + +use openapi_to_rust::{ + CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, + type_mapping::{ConstraintMode, TypeConstraintsConfig}, +}; +use serde_json::json; + +fn spec_with_property(prop: serde_json::Value) -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "c", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Sample": { + "type": "object", + "required": ["value"], + "properties": { "value": prop } + } + } + } + }) +} + +fn generate(spec: serde_json::Value, types_cfg: TypeMappingConfig) -> String { + // Threading the *same* TypeMappingConfig into both the analyzer + // (via TypeMapper) and the generator's GeneratorConfig.types so + // analysis-time and codegen-time decisions stay consistent. The + // production CLI does this in src/bin/openapi-to-rust.rs. + let mapper = TypeMapper::new(types_cfg.clone()); + let mut analyzer = SchemaAnalyzer::with_type_mapper(spec, mapper).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + types: types_cfg, + ..Default::default() + }; + CodeGenerator::new(cfg) + .generate(&mut analysis) + .expect("generate") +} + +#[test] +fn integer_minimum_maximum_emits_doc_comment_by_default() { + let code = generate( + spec_with_property(json!({ + "type": "integer", + "format": "int32", + "minimum": 0, + "maximum": 100 + })), + TypeMappingConfig::default(), + ); + assert!( + code.contains("Constraint: minimum=0, maximum=100"), + "Expected constraint doc comment. Code:\n{code}" + ); +} + +#[test] +fn string_min_max_length_and_pattern_render_in_doc() { + let code = generate( + spec_with_property(json!({ + "type": "string", + "minLength": 3, + "maxLength": 32, + "pattern": "^[a-z]+$" + })), + TypeMappingConfig::default(), + ); + assert!( + code.contains("minLength=3"), + "Expected minLength in constraint doc. Code:\n{code}" + ); + assert!( + code.contains("maxLength=32"), + "Expected maxLength in constraint doc. Code:\n{code}" + ); + assert!( + code.contains("pattern=`^[a-z]+$`"), + "Expected pattern wrapped in backticks. Code:\n{code}" + ); +} + +#[test] +fn array_min_max_items_and_unique_items_render_in_doc() { + let code = generate( + spec_with_property(json!({ + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": 5, + "uniqueItems": true + })), + TypeMappingConfig::default(), + ); + assert!( + code.contains("minItems=1"), + "Expected minItems in doc. Code:\n{code}" + ); + assert!( + code.contains("maxItems=5"), + "Expected maxItems in doc. Code:\n{code}" + ); + assert!( + code.contains("uniqueItems=true"), + "Expected uniqueItems=true in doc. Code:\n{code}" + ); +} + +#[test] +fn no_constraints_emits_no_constraint_doc_line() { + let code = generate( + spec_with_property(json!({ "type": "integer" })), + TypeMappingConfig::default(), + ); + assert!( + !code.contains("Constraint:"), + "Field with no constraints must not get a constraint doc. Code:\n{code}" + ); +} + +#[test] +fn mode_off_suppresses_doc_comment() { + let mut cfg = TypeMappingConfig::default(); + cfg.constraints = Some(TypeConstraintsConfig { + mode: Some(ConstraintMode::Off), + }); + let code = generate( + spec_with_property(json!({ + "type": "integer", + "minimum": 0 + })), + cfg, + ); + assert!( + !code.contains("Constraint:"), + "mode = off should suppress constraint doc. Code:\n{code}" + ); +} + +#[test] +fn pattern_with_triple_slash_is_escaped() { + // Pathological but legal: a regex containing `///`. Without + // escaping, the doc comment would terminate early. + let code = generate( + spec_with_property(json!({ + "type": "string", + "pattern": "abc///def" + })), + TypeMappingConfig::default(), + ); + // The literal `///` substring should NOT appear inside the + // pattern-doc backticks. We escape with a zero-width-space. + assert!( + !code.contains("pattern=`abc///def`"), + "Triple-slash inside pattern must be escaped. Code:\n{code}" + ); + // The escaped form should still be present. + assert!( + code.contains("pattern=`abc/"), + "Expected escaped pattern to render. Code:\n{code}" + ); +} + +#[test] +fn no_validate_attribute_is_ever_emitted() { + // Regression guard: the generator must never emit + // #[validate(...)] regardless of input. Client-side validation + // is intentionally out of scope. + let code = generate( + spec_with_property(json!({ + "type": "integer", + "minimum": 0, + "maximum": 100 + })), + TypeMappingConfig::default(), + ); + assert!( + !code.contains("#[validate"), + "Generator must never emit #[validate(...)] — client-side validation is out of scope. Code:\n{code}" + ); + assert!( + !code.contains("validator::"), + "Generator must not reference the validator crate. Code:\n{code}" + ); +} + +#[test] +fn float_constraint_renders_with_decimal() { + let code = generate( + spec_with_property(json!({ + "type": "number", + "format": "double", + "minimum": 0.5, + "maximum": 99.95 + })), + TypeMappingConfig::default(), + ); + assert!( + code.contains("minimum=0.5"), + "Expected float minimum. Code:\n{code}" + ); + assert!( + code.contains("maximum=99.95"), + "Expected float maximum. Code:\n{code}" + ); +} diff --git a/tests/fixture_tests.rs b/tests/fixture_tests.rs index 6b1097c..353cef2 100644 --- a/tests/fixture_tests.rs +++ b/tests/fixture_tests.rs @@ -36,6 +36,12 @@ edition = "2021" [dependencies] serde = {{ version = "1.0", features = ["derive"] }} serde_json = "1.0" +# Q2 typed-scalar deps (default-on; harmless when unused). +chrono = {{ version = "0.4", features = ["serde"] }} +uuid = {{ version = "1", features = ["serde", "v4"] }} +url = {{ version = "2", features = ["serde"] }} +bytes = {{ version = "1", features = ["serde"] }} +base64 = "0.22" "# ); diff --git a/tests/http_error_test.rs b/tests/http_error_test.rs index b5b659f..8059609 100644 --- a/tests/http_error_test.rs +++ b/tests/http_error_test.rs @@ -229,6 +229,8 @@ fn test_generated_error_code() { type_mappings: BTreeMap::new(), }, operations: BTreeMap::new(), + used_type_features: Default::default(), + enum_extensions: BTreeMap::new(), }; // Generate HTTP client code which includes error types diff --git a/tests/integer_formats_test.rs b/tests/integer_formats_test.rs new file mode 100644 index 0000000..905ee7b --- /dev/null +++ b/tests/integer_formats_test.rs @@ -0,0 +1,171 @@ +//! Q2.1 + Q2.2: end-to-end checks that unsigned integer formats +//! and built-in format aliases reach the generated Rust. + +use openapi_to_rust::{ + CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, +}; +use serde_json::json; + +fn integer_spec(format: &str) -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "ints", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Sample": { + "type": "object", + "required": ["value"], + "properties": { + "value": { "type": "integer", "format": format } + } + } + } + } + }) +} + +fn string_spec(format: &str) -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "strs", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Sample": { + "type": "object", + "required": ["value"], + "properties": { + "value": { "type": "string", "format": format } + } + } + } + } + }) +} + +fn generate(spec: serde_json::Value, mapper: TypeMapper) -> String { + let mut analyzer = SchemaAnalyzer::with_type_mapper(spec, mapper).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + ..Default::default() + }; + CodeGenerator::new(cfg) + .generate(&mut analysis) + .expect("generate") +} + +#[test] +fn uint32_default_emits_u32() { + let code = generate( + integer_spec("uint32"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: u32"), + "uint32 should map to u32 by default. Code:\n{code}" + ); +} + +#[test] +fn uint64_default_emits_u64() { + let code = generate( + integer_spec("uint64"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: u64"), + "uint64 should map to u64 by default. Code:\n{code}" + ); +} + +#[test] +fn unsigned_off_degrades_uint64_to_i64() { + let mut cfg = TypeMappingConfig::default(); + cfg.unsigned = false; + let code = generate(integer_spec("uint64"), TypeMapper::new(cfg)); + assert!( + code.contains("pub value: i64"), + "unsigned = false should fall back to i64 for uint64. Code:\n{code}" + ); +} + +#[test] +fn int32_int64_unchanged() { + let code = generate( + integer_spec("int32"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: i32"), + "int32 should still map to i32. Code:\n{code}" + ); + + let code = generate( + integer_spec("int64"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: i64"), + "int64 should still map to i64. Code:\n{code}" + ); +} + +#[test] +fn builtin_alias_uuid4_resolves_to_uuid_uuid() { + let code = generate( + string_spec("uuid4"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: uuid::Uuid"), + "format: uuid4 should normalize to uuid::Uuid via built-in alias. Code:\n{code}" + ); +} + +#[test] +fn builtin_alias_unix_time_resolves_to_i64() { + let code = generate( + integer_spec("unix-time"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: i64"), + "format: unix-time on integer should normalize to int64 via alias. Code:\n{code}" + ); +} + +#[test] +fn user_format_alias_overrides_builtin() { + let mut cfg = TypeMappingConfig::default(); + cfg.format_aliases + .insert("uuid4".to_string(), "hostname".to_string()); + let code = generate(string_spec("uuid4"), TypeMapper::new(cfg)); + // hostname is unmapped → falls through to plain String. + assert!( + code.contains("pub value: String"), + "user alias should override built-in. Code:\n{code}" + ); +} + +#[test] +fn conservative_disables_uint_and_aliases() { + // Conservative collapses everything; uint64 falls to i64 and + // alias paths still normalize but the underlying strategies + // produce String for typed targets. + let cfg = TypeMappingConfig::conservative(); + let code = generate(integer_spec("uint64"), TypeMapper::new(cfg.clone())); + assert!( + code.contains("pub value: i64"), + "conservative should keep uint64 as i64. Code:\n{code}" + ); + + let code = generate(string_spec("uuid4"), TypeMapper::new(cfg)); + // Alias still normalizes uuid4→uuid, but uuid strategy is + // String under conservative, so the final type is String. + assert!( + code.contains("pub value: String"), + "conservative + uuid4 should still render as String. Code:\n{code}" + ); +} diff --git a/tests/multi_response_client_test.rs b/tests/multi_response_client_test.rs index 84d11f0..572b536 100644 --- a/tests/multi_response_client_test.rs +++ b/tests/multi_response_client_test.rs @@ -27,6 +27,12 @@ reqwest-middleware = { version = "0.4", features = ["multipart"] } thiserror = "1.0" tokio = { version = "1.0", features = ["full"] } validator = { version = "0.20", features = ["derive"] } +# Q2 typed-scalar deps (default-on; harmless when unused). +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["serde", "v4"] } +url = { version = "2", features = ["serde"] } +bytes = { version = "1", features = ["serde"] } +base64 = "0.22" "#; /// Generate types + http_client for a spec, write a compilable crate to a diff --git a/tests/operation_generation_test.rs b/tests/operation_generation_test.rs index 0deee2b..ff39b74 100644 --- a/tests/operation_generation_test.rs +++ b/tests/operation_generation_test.rs @@ -27,6 +27,8 @@ fn create_test_analysis_with_operations(operations: Vec) -> Schem type_mappings: Default::default(), }, operations: ops_map, + used_type_features: Default::default(), + enum_extensions: BTreeMap::new(), } } diff --git a/tests/primitive_unions_test.rs b/tests/primitive_unions_test.rs new file mode 100644 index 0000000..223d7e3 --- /dev/null +++ b/tests/primitive_unions_test.rs @@ -0,0 +1,164 @@ +//! Q2.7: untagged enums for `oneOf` / `anyOf` of primitives. +//! +//! Asserts that primitive unions emit a clean +//! `#[serde(untagged)] pub enum X { String(String), Integer(i64), … }` +//! by default (`primitive_unions = true`), and revert to the +//! pre-Q2.7 type-alias-per-variant shape when the toggle is off. + +use openapi_to_rust::{ + CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, + type_mapping::TypeShapeConfig, +}; +use serde_json::json; + +fn generate(spec: serde_json::Value, mapper: TypeMapper) -> String { + let mut analyzer = SchemaAnalyzer::with_type_mapper(spec, mapper).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + ..Default::default() + }; + let codegen = CodeGenerator::new(cfg); + codegen.generate(&mut analysis).expect("generate") +} + +fn primitive_union_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "p", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "OneOfId": { + "oneOf": [{ "type": "string" }, { "type": "integer" }] + }, + "AnyOfId": { + "anyOf": [{ "type": "string" }, { "type": "integer" }] + }, + "Triple": { + "anyOf": [ + { "type": "string" }, + { "type": "integer" }, + { "type": "boolean" } + ] + } + } + } + }) +} + +#[test] +fn oneof_primitives_default_emits_untagged_enum_with_primitive_variants() { + let code = generate( + primitive_union_spec(), + TypeMapper::new(TypeMappingConfig::default()), + ); + // The `oneOf` path was already producing the right shape pre-Q2.7; + // this test pins the behavior so a future refactor can't regress it. + assert!( + code.contains("#[serde(untagged)]\npub enum OneOfId"), + "OneOfId should be a #[serde(untagged)] enum. Code:\n{code}" + ); + assert!( + code.contains("String(String)") && code.contains("Integer(i64)"), + "OneOfId should have String(String) and Integer(i64) variants. Code:\n{code}" + ); +} + +#[test] +fn anyof_primitives_default_emits_clean_untagged_enum() { + let code = generate( + primitive_union_spec(), + TypeMapper::new(TypeMappingConfig::default()), + ); + // Pre-Q2.7 emitted + // pub type AnyOfIdString = String; + // pub type AnyOfIdIntegerVariant1 = i64; + // pub enum AnyOfId { AnyOfIdString(AnyOfIdString), … } + // Q2.7 collapses that to the same shape oneOf already used. + assert!( + code.contains("pub enum AnyOfId {\n String(String),\n Integer(i64),\n}"), + "AnyOfId should match the oneOf shape, no per-variant type aliases. Code:\n{code}" + ); + // Negative: no per-variant type alias should remain. + assert!( + !code.contains("pub type AnyOfIdString"), + "Pre-Q2.7 type alias must not be emitted under default config. Code:\n{code}" + ); +} + +#[test] +fn anyof_three_primitives_default_emits_three_variants() { + let code = generate( + primitive_union_spec(), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains( + "pub enum Triple {\n String(String),\n Integer(i64),\n Boolean(bool),\n}" + ), + "Triple should emit one variant per primitive type. Code:\n{code}" + ); +} + +#[test] +fn anyof_primitives_with_toggle_off_reverts_to_type_aliases() { + let mut cfg = TypeMappingConfig::default(); + cfg.shape = Some(TypeShapeConfig { + primitive_unions: Some(false), + ..Default::default() + }); + let code = generate(primitive_union_spec(), TypeMapper::new(cfg)); + // Pre-Q2.7 shape: per-variant type aliases. + assert!( + code.contains("pub type AnyOfIdString"), + "Pre-Q2.7 type alias should reappear when primitive_unions = false. Code:\n{code}" + ); +} + +#[test] +fn anyof_with_explicit_null_variant_drops_null() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "p", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Nullable": { + "anyOf": [ + { "type": "string" }, + { "type": "integer" }, + { "type": "null" } + ] + } + } + } + }); + let code = generate(spec, TypeMapper::new(TypeMappingConfig::default())); + // Null variant is filtered (nullability surfaces as Option at the + // property level via is_nullable_pattern); the enum just holds the + // non-null primitives. + assert!( + code.contains("pub enum Nullable {\n String(String),\n Integer(i64),\n}"), + "Null variant should be dropped from the union. Code:\n{code}" + ); +} + +#[test] +fn primitive_union_round_trips_each_variant() { + // Lightweight schema-level guarantee: serde_json round-trips + // each primitive case via the same untagged enum without a + // discriminator field. Requires building a tiny ad-hoc crate + // would be overkill — instead assert the generated code + // contains the #[serde(untagged)] attribute (which is what + // makes round-trips work) and the right variants. + let code = generate( + primitive_union_spec(), + TypeMapper::new(TypeMappingConfig::default()), + ); + let derived_count = code.matches("#[serde(untagged)]").count(); + assert!( + derived_count >= 3, + "Expected at least 3 #[serde(untagged)] enums (OneOfId, AnyOfId, Triple). Code:\n{code}" + ); +} diff --git a/tests/typed_scalars_test.rs b/tests/typed_scalars_test.rs new file mode 100644 index 0000000..4f54193 --- /dev/null +++ b/tests/typed_scalars_test.rs @@ -0,0 +1,308 @@ +//! End-to-end checks for Q2 typed-scalar generation. +//! +//! Asserts that an OpenAPI property declared as `type: string, +//! format: ` lands in the generated Rust as the right typed +//! scalar (chrono::DateTime, uuid::Uuid, …) under the default +//! [`TypeMappingConfig`] and as plain `String` under +//! `TypeMappingConfig::conservative()`. +//! +//! Lives at the integration layer because the wiring crosses +//! `analysis.rs`, `generator.rs`, and `type_mapping.rs`; a unit test +//! only on `TypeMapper` would miss the codec threading through +//! `SchemaType::Primitive.serde_with`. + +use openapi_to_rust::{ + CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, +}; +use serde_json::json; + +fn spec_with_format(format: &str) -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "fmt", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Sample": { + "type": "object", + "required": ["value"], + "properties": { + "value": { "type": "string", "format": format } + } + } + } + } + }) +} + +fn generate(spec: serde_json::Value, mapper: TypeMapper) -> String { + let mut analyzer = SchemaAnalyzer::with_type_mapper(spec, mapper).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + ..Default::default() + }; + let codegen = CodeGenerator::new(cfg); + codegen.generate(&mut analysis).expect("generate") +} + +#[test] +fn date_time_default_emits_chrono_datetime() { + let code = generate( + spec_with_format("date-time"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: chrono::DateTime"), + "date-time should map to chrono::DateTime by default. Code:\n{code}" + ); +} + +#[test] +fn date_time_conservative_emits_string() { + let code = generate( + spec_with_format("date-time"), + TypeMapper::new(TypeMappingConfig::conservative()), + ); + assert!( + code.contains("pub value: String"), + "date-time with conservative config should be String. Code:\n{code}" + ); + assert!( + !code.contains("chrono::"), + "conservative config must not reference chrono. Code:\n{code}" + ); +} + +#[test] +fn uuid_default_emits_uuid_uuid() { + let code = generate( + spec_with_format("uuid"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: uuid::Uuid"), + "uuid should map to uuid::Uuid by default. Code:\n{code}" + ); +} + +#[test] +fn uri_default_emits_url_url() { + let code = generate( + spec_with_format("uri"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: url::Url"), + "uri should map to url::Url by default. Code:\n{code}" + ); +} + +#[test] +fn ipv4_default_emits_std_net_ipv4addr() { + let code = generate( + spec_with_format("ipv4"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: std::net::Ipv4Addr"), + "ipv4 should map to std::net::Ipv4Addr by default. Code:\n{code}" + ); +} + +#[test] +fn binary_default_emits_bytes_bytes() { + let code = generate( + spec_with_format("binary"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: bytes::Bytes"), + "binary should map to bytes::Bytes by default. Code:\n{code}" + ); +} + +#[test] +fn byte_default_emits_vec_u8_with_base64_codec() { + let code = generate( + spec_with_format("byte"), + TypeMapper::new(TypeMappingConfig::default()), + ); + // Type + assert!( + code.contains("pub value: Vec"), + "byte should map to Vec. Code:\n{code}" + ); + // Codec attribute on the field + assert!( + code.contains(r#"with = "base64_serde""#), + "byte field should carry #[serde(with = \"base64_serde\")]. Code:\n{code}" + ); + // Helper module emitted exactly once + assert!( + code.contains("mod base64_serde"), + "Generated file should include the base64_serde helper module. Code:\n{code}" + ); +} + +#[test] +fn no_byte_format_no_base64_helper_emitted() { + // Sanity: helper module is gated on actual usage, so a spec + // that uses date-time/uuid but never byte must not include it. + let code = generate( + spec_with_format("date-time"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + !code.contains("mod base64_serde"), + "base64_serde must not be emitted when no field uses format: byte. Code:\n{code}" + ); +} + +#[test] +fn unknown_format_falls_through_to_string() { + let code = generate( + spec_with_format("hostname"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("pub value: String"), + "Unknown format should fall through to String. Code:\n{code}" + ); +} + +#[test] +fn required_deps_are_populated_for_typed_scalars() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "fmt", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Sample": { + "type": "object", + "required": ["a", "b", "c", "d"], + "properties": { + "a": { "type": "string", "format": "date-time" }, + "b": { "type": "string", "format": "uuid" }, + "c": { "type": "string", "format": "uri" }, + "d": { "type": "string", "format": "byte" } + } + } + } + } + }); + let mut analyzer = + SchemaAnalyzer::with_type_mapper(spec, TypeMapper::default()).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + ..Default::default() + }; + let codegen = CodeGenerator::new(cfg); + let result = codegen.generate_all(&mut analysis).expect("generate_all"); + + let crate_names: Vec<&str> = result.required_deps.iter().map(|d| d.crate_name).collect(); + // Sorted, deterministic ordering. + assert_eq!(crate_names, vec!["base64", "chrono", "url", "uuid"]); +} + +#[test] +fn required_deps_empty_for_pure_string_spec() { + let spec = spec_with_format("hostname"); // unknown format → String + let mut analyzer = + SchemaAnalyzer::with_type_mapper(spec, TypeMapper::default()).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + ..Default::default() + }; + let codegen = CodeGenerator::new(cfg); + let result = codegen.generate_all(&mut analysis).expect("generate_all"); + + assert!( + result.required_deps.is_empty(), + "spec with no typed scalars should have empty required_deps. Got: {:?}", + result.required_deps + ); +} + +#[test] +fn write_files_drops_required_deps_toml_when_typed_scalars_used() { + let spec = spec_with_format("date-time"); + let mut analyzer = + SchemaAnalyzer::with_type_mapper(spec, TypeMapper::default()).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + + let temp = tempfile::TempDir::new().expect("temp"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + output_dir: temp.path().into(), + ..Default::default() + }; + let codegen = CodeGenerator::new(cfg); + let result = codegen.generate_all(&mut analysis).expect("generate_all"); + codegen.write_files(&result).expect("write_files"); + + let deps_path = temp.path().join("REQUIRED_DEPS.toml"); + assert!( + deps_path.exists(), + "REQUIRED_DEPS.toml should be written when typed scalars are used" + ); + let body = std::fs::read_to_string(&deps_path).expect("read deps file"); + assert!(body.contains("[dependencies]"), "body:\n{body}"); + assert!(body.contains("chrono = "), "body:\n{body}"); + assert!( + body.contains("# Generated by openapi-to-rust"), + "should include explanatory header. body:\n{body}" + ); +} + +#[test] +fn write_files_skips_required_deps_toml_when_no_typed_scalars() { + let spec = spec_with_format("hostname"); + let mut analyzer = + SchemaAnalyzer::with_type_mapper(spec, TypeMapper::default()).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + + let temp = tempfile::TempDir::new().expect("temp"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + output_dir: temp.path().into(), + ..Default::default() + }; + let codegen = CodeGenerator::new(cfg); + let result = codegen.generate_all(&mut analysis).expect("generate_all"); + codegen.write_files(&result).expect("write_files"); + + let deps_path = temp.path().join("REQUIRED_DEPS.toml"); + assert!( + !deps_path.exists(), + "REQUIRED_DEPS.toml should NOT be written when no typed scalars are used" + ); +} + +#[test] +fn no_format_property_remains_string() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "fmt", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Sample": { + "type": "object", + "required": ["value"], + "properties": { + "value": { "type": "string" } + } + } + } + } + }); + let code = generate(spec, TypeMapper::new(TypeMappingConfig::default())); + assert!( + code.contains("pub value: String"), + "string with no format must remain String. Code:\n{code}" + ); +} diff --git a/tests/x_enum_varnames_test.rs b/tests/x_enum_varnames_test.rs new file mode 100644 index 0000000..b942720 --- /dev/null +++ b/tests/x_enum_varnames_test.rs @@ -0,0 +1,173 @@ +//! Q2.6 — vendor extensions `x-enum-varnames` and +//! `x-enum-descriptions` shape the generated string-enum variants. +//! `x-enum-varnames` overrides the default PascalCase heuristic; +//! `x-enum-descriptions` attaches per-variant doc comments. + +use openapi_to_rust::{ + CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, + type_mapping::TypeEnumsConfig, +}; +use serde_json::json; + +fn enum_spec(values: serde_json::Value, extensions: serde_json::Value) -> serde_json::Value { + let mut sample = json!({ + "type": "string", + "enum": values + }); + let s_obj = sample.as_object_mut().unwrap(); + if let Some(obj) = extensions.as_object() { + for (k, v) in obj { + s_obj.insert(k.clone(), v.clone()); + } + } + json!({ + "openapi": "3.1.0", + "info": { "title": "e", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { "Status": sample } + } + }) +} + +fn generate(spec: serde_json::Value, types_cfg: TypeMappingConfig) -> String { + let mapper = TypeMapper::new(types_cfg.clone()); + let mut analyzer = SchemaAnalyzer::with_type_mapper(spec, mapper).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + types: types_cfg, + ..Default::default() + }; + CodeGenerator::new(cfg) + .generate(&mut analysis) + .expect("generate") +} + +#[test] +fn x_enum_varnames_overrides_default_pascalcase() { + let code = generate( + enum_spec( + json!(["active", "inactive", "pending_review"]), + json!({ + "x-enum-varnames": ["StatusActive", "StatusInactive", "StatusPendingReview"] + }), + ), + TypeMappingConfig::default(), + ); + // Variant identifiers come from x-enum-varnames. + assert!( + code.contains("StatusActive"), + "Expected StatusActive variant from x-enum-varnames. Code:\n{code}" + ); + assert!( + code.contains("StatusPendingReview"), + "Expected StatusPendingReview variant. Code:\n{code}" + ); + // Wire format is preserved via #[serde(rename = "")]. + assert!( + code.contains(r#"#[serde(rename = "active")]"#), + "Wire format must be preserved via #[serde(rename = ...)]. Code:\n{code}" + ); + assert!( + code.contains(r#"#[serde(rename = "pending_review")]"#), + "Wire format must be preserved via #[serde(rename = ...)]. Code:\n{code}" + ); +} + +#[test] +fn x_enum_descriptions_emit_per_variant_doc_comments() { + let code = generate( + enum_spec( + json!(["fast", "slow"]), + json!({ + "x-enum-descriptions": ["Quick path", "Slow path"] + }), + ), + TypeMappingConfig::default(), + ); + assert!( + code.contains("Quick path"), + "Expected variant doc 'Quick path'. Code:\n{code}" + ); + assert!( + code.contains("Slow path"), + "Expected variant doc 'Slow path'. Code:\n{code}" + ); +} + +#[test] +fn extension_length_mismatch_is_silently_dropped() { + // When x-enum-varnames length doesn't match the enum array, the + // analysis layer warns and drops the extension entirely. Codegen + // falls back to the default PascalCase heuristic. + let code = generate( + enum_spec( + json!(["a", "b", "c"]), + json!({ + "x-enum-varnames": ["VariantA", "VariantB"] // length 2, enum length 3 + }), + ), + TypeMappingConfig::default(), + ); + // Default heuristic should produce A/B/C, not VariantA/VariantB. + assert!( + !code.contains("VariantA"), + "Length-mismatched x-enum-varnames must not be applied. Code:\n{code}" + ); +} + +#[test] +fn x_enum_varnames_disabled_falls_back_to_heuristic() { + let mut cfg = TypeMappingConfig::default(); + cfg.enums = Some(TypeEnumsConfig { + x_enum_varnames: Some(false), + x_enum_descriptions: None, + }); + let code = generate( + enum_spec( + json!(["active", "inactive"]), + json!({ + "x-enum-varnames": ["StatusActive", "StatusInactive"] + }), + ), + cfg, + ); + assert!( + !code.contains("StatusActive"), + "x_enum_varnames = false must not honor the extension. Code:\n{code}" + ); +} + +#[test] +fn x_enum_descriptions_disabled_drops_doc_comments() { + let mut cfg = TypeMappingConfig::default(); + cfg.enums = Some(TypeEnumsConfig { + x_enum_varnames: None, + x_enum_descriptions: Some(false), + }); + let code = generate( + enum_spec( + json!(["fast", "slow"]), + json!({ "x-enum-descriptions": ["Quick path", "Slow path"] }), + ), + cfg, + ); + assert!( + !code.contains("Quick path"), + "x_enum_descriptions = false must drop doc comments. Code:\n{code}" + ); +} + +#[test] +fn no_extensions_renders_default_heuristic() { + let code = generate( + enum_spec(json!(["asc", "desc"]), json!({})), + TypeMappingConfig::default(), + ); + // No extensions present → to_rust_enum_variant heuristic. + assert!( + code.contains("Asc") && code.contains("Desc"), + "Default heuristic should produce Asc/Desc. Code:\n{code}" + ); +}