From b3e051e203398a69f6f6e767404200f31e5df1b4 Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 17 Jul 2026 08:52:18 -0600 Subject: [PATCH 1/2] fix: hoist inline string enums in array items to named enums Array items with `type: string` + `enum` collapsed to `Vec` because analyze_array_schema mapped string items straight to a String primitive, while property-level inline enums were already hoisted to named StringEnum types. Extract the property path's hoisting (naming, same-value dedup, collision disambiguation) into hoist_inline_string_enum and route array items through it, naming item enums `{Parent}Item`. Covers plain arrays, anyOf-nullable arrays, and typeless OpenAPI 3.1 enum items. Fixes #33 Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 1 + CHANGELOG.md | 3 + src/analysis.rs | 267 ++++++++++-------- ...lpers__array_item_enum_disambiguation.snap | 77 +++++ ..._test_helpers__array_item_string_enum.snap | 44 +++ ...lpers__nullable_anyof_array_item_enum.snap | 44 +++ ...est_helpers__plain_string_array_items.snap | 18 ++ ...est_helpers__typeless_array_item_enum.snap | 47 +++ tests/array_item_enum_test.rs | 221 +++++++++++++++ 9 files changed, 612 insertions(+), 110 deletions(-) create mode 100644 src/snapshots/openapi_to_rust__test_helpers__array_item_enum_disambiguation.snap create mode 100644 src/snapshots/openapi_to_rust__test_helpers__array_item_string_enum.snap create mode 100644 src/snapshots/openapi_to_rust__test_helpers__nullable_anyof_array_item_enum.snap create mode 100644 src/snapshots/openapi_to_rust__test_helpers__plain_string_array_items.snap create mode 100644 src/snapshots/openapi_to_rust__test_helpers__typeless_array_item_enum.snap create mode 100644 tests/array_item_enum_test.rs diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3643d17..20a05ba 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"id":"openapi-generator-554","title":"Array items with inline string enum generate Vec\u003cString\u003e instead of named enum (gh#33)","description":"GitHub issue gpu-cli/openapi-to-rust#33: analyze_array_schema maps string array items straight to String, ignoring enum values, while analyze_property_schema_with_context hoists inline string enums into named types. Fix: extract the enum-hoisting logic into a shared helper and use it for array items too.","status":"open","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-17T14:40:53Z","created_by":"James Lal","updated_at":"2026-07-17T14:40:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dj7","title":"Distill and harden developer-first homepage","notes":"Rewrote homepage from 1,007 to 603 visible words (40.1% reduction) and 10 to 6 sections. Hero now states exact output and two-command trial; CI proof separates active PR tier from configured scheduled/manual full-corpus tier; real model/client/server artifacts are linked; repeated eyebrow/grid/window-dot patterns removed; spacing and z-index tokens added; copy failure is visible; neutral background replaces decorative cream grid. Impeccable critique trend 30→36→38 with 0 P0/P1. Gates: Astro 0 diagnostics; 9 pages built; html-validate pass; SEO audit pass; detector and layout detector clean. Playwright MCP and isolated critique contexts verified 1440x1000, 768x1024, 390x844: no page/section/H1 overflow, no console errors, copy/menu/FAQ/focus/touch targets pass. Production deployment dpl_9qhn2wtuiYUYuPsSdduALvGUSicu READY and aliased to https://openapi-to-rust.dev; live desktop/mobile Playwright plus canonical/robots/sitemap/security-header/404 checks pass.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:53:05Z","created_by":"James Lal","updated_at":"2026-07-16T05:33:51Z","started_at":"2026-07-16T04:53:11Z","closed_at":"2026-07-16T05:33:51Z","close_reason":"Developer-first homepage meets all stopping criteria, is deployed, visually verified, and scores 38/40 with no P0/P1 findings.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-gl9","title":"Build and deploy SEO-first Astro documentation site","description":"Create an Astro landing page and documentation site for openapi-to-rust, targeting Rust OpenAPI code-generation search intent; include strong technical SEO, accessible responsive design, local QA, and Vercel deployment.","design":"Host the site in a self-contained website directory so Rust crate workflows remain isolated. Favor static HTML, minimal JavaScript, content-rich documentation, honest comparisons, and code-native visuals.","acceptance_criteria":"Astro site builds successfully; landing page targets openapi generator rust and adjacent intent; metadata, canonical URLs, sitemap, robots, structured data, and social cards are present; responsive and accessible UI is browser-tested and critiqued; deployment succeeds on Vercel; all changes are committed and pushed.","notes":"Implemented an SEO-first Astro site under website/ with 8 indexable routes plus a custom 404; canonical origin https://openapi-to-rust.dev; deployed to Vercel project lbl-rd/openapi-to-rust; Git integration root is website; custom domain verified. Validation: remote Vercel build clean, Astro check clean, SEO audit passed for 9 rendered pages, html-validate passed, live HTTP/canonical/sitemap/security-header/404 checks passed. Playwright browser bridge was present but exposed zero browser backends, so visual testing used independent source/rendered-HTML critique and OG image inspection rather than screenshots.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:03:33Z","created_by":"James Lal","updated_at":"2026-07-16T04:34:21Z","started_at":"2026-07-16T04:03:57Z","closed_at":"2026-07-16T04:34:21Z","close_reason":"SEO site implemented, critiqued, validated, deployed, and connected to the custom domain and Git repository.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-1sb.9","title":"Fix cargo install packaging and generator dependency bloat","description":"Make cargo install --locked install only openapi-to-rust, add --version, align reqwest 0.12, remove emitted-token-only runtime dependencies, gate test helpers, trim features, and add install and dependency-tree regression coverage.","acceptance_criteria":"Only the public CLI is installed; --version works; default dependency tree has no reqwest 0.11; test-only crates are absent from the installed product; temporary-root install smoke performs minimal generation.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:32:25Z","created_by":"James Lal","updated_at":"2026-07-15T14:32:58Z","closed_at":"2026-07-15T14:32:58Z","labels":["accessibility","adoption","cli","dependencies","packaging"],"dependencies":[{"issue_id":"openapi-generator-1sb.9","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:32:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.9","depends_on_id":"openapi-generator-1sb.8","type":"supersedes","created_at":"2026-07-15T08:32:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c098a..4d5b297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ when correcting output that was wrong or incomplete on the wire. ### Fixed +- Array items with an inline string enum now generate a named enum + (`{Parent}Item`) instead of collapsing to `Vec`, including + `anyOf`-nullable arrays and typeless OpenAPI 3.1 enums (#33). - README compatibility, corpus, and conformance claims; pull-request and scheduled full-corpus CI tiers; and release preflight checks. - Canonical `[generator.types]` configuration parsing, strict unknown-field diff --git a/src/analysis.rs b/src/analysis.rs index 0ba8b19..59af4ac 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -1941,108 +1941,12 @@ impl SchemaAnalyzer { format!("{context_name}{suffix}") }; - // Resolve a name that either matches an existing same-valued - // enum (dedup) or doesn't collide with a different one. - // - // Two distinct inline enums can land on the same primary - // candidate when a parent schema has a property like - // `type` that recurs at multiple nesting levels — e.g. - // Latitude.sh's `plan_data.type = ["plans"]` (the - // JSON-API resource type) and - // `plan_data.attributes.specs.drives[].type = - // ["SSD","HDD","NVME"]` both want to become - // `PlanDataType`. We must NOT silently overwrite the - // first registration: that breaks deserialization - // because both fields end up referencing whichever - // enum was processed last. - // - // Disambiguation strategy: append the PascalCase first - // enum value (`PlanDataTypeNVME` vs `PlanDataTypePlans`) - // and, if that's also claimed with different values, - // fall back to a numeric `_2`, `_3`, … suffix. - fn matches_values(existing: &AnalyzedSchema, values: &[String]) -> bool { - matches!( - &existing.schema_type, - SchemaType::StringEnum { values: existing_values } - if existing_values == values - ) - } - - let mut enum_type_name = primary_name.clone(); - let mut should_insert = match self.resolved_cache.get(&enum_type_name) { - None => true, - Some(existing) if matches_values(existing, &enum_values) => false, - Some(_) => { - // Collision with different values — try a - // value-suffixed name first. - let suffix = enum_values - .first() - .map(|v| self.to_pascal_case(v)) - .unwrap_or_else(|| "Variant".to_string()); - let candidate = format!("{primary_name}{suffix}"); - - let resolved = match self.resolved_cache.get(&candidate) { - None => Some((candidate.clone(), true)), - Some(existing) if matches_values(existing, &enum_values) => { - Some((candidate.clone(), false)) - } - Some(_) => { - // Walk a numeric suffix until we find - // a slot that's free or matches. - let mut found = None; - for n in 2..1000 { - let numbered = format!("{candidate}_{n}"); - match self.resolved_cache.get(&numbered) { - None => { - found = Some((numbered, true)); - break; - } - Some(existing) - if matches_values(existing, &enum_values) => - { - found = Some((numbered, false)); - break; - } - Some(_) => continue, - } - } - found - } - }; - - let (resolved_name, insert) = resolved.unwrap_or((candidate, true)); - enum_type_name = resolved_name; - insert - } - }; - - // Store the enum as a named schema if this is the - // first time we've seen this exact (name, values) pair. - if should_insert { - self.resolved_cache.insert( - enum_type_name.clone(), - AnalyzedSchema { - name: enum_type_name.clone(), - original: serde_json::to_value(schema).unwrap_or(Value::Null), - schema_type: SchemaType::StringEnum { - values: enum_values, - }, - dependencies: HashSet::new(), - nullable: false, - description: schema.details().description.clone(), - default: schema.details().default.clone(), - }, - ); - // Silence unused-write warnings when the value - // is not consulted again on this path. - let _ = &mut should_insert; - } - - // Return a reference to the named enum type - dependencies.insert(enum_type_name.clone()); - return Ok(SchemaType::Reference { - target: enum_type_name, - }); + return Ok(self.hoist_inline_string_enum( + schema, + enum_values, + primary_name, + dependencies, + )); } else { // Property-level string with no enum values: // route through TypeMapper so `format: date-time` @@ -3484,6 +3388,116 @@ impl SchemaAnalyzer { } } + /// Register an inline string enum as a named `StringEnum` schema and + /// return a `Reference` to it. Shared by property-level enums + /// (`{Schema}{Prop}`) and array-item enums (`{Schema}{Prop}Item`). + /// + /// Resolves a name that either matches an existing same-valued + /// enum (dedup) or doesn't collide with a different one. + /// + /// Two distinct inline enums can land on the same primary + /// candidate when a parent schema has a property like + /// `type` that recurs at multiple nesting levels — e.g. + /// Latitude.sh's `plan_data.type = ["plans"]` (the + /// JSON-API resource type) and + /// `plan_data.attributes.specs.drives[].type = + /// ["SSD","HDD","NVME"]` both want to become + /// `PlanDataType`. We must NOT silently overwrite the + /// first registration: that breaks deserialization + /// because both fields end up referencing whichever + /// enum was processed last. + /// + /// Disambiguation strategy: append the PascalCase first + /// enum value (`PlanDataTypeNVME` vs `PlanDataTypePlans`) + /// and, if that's also claimed with different values, + /// fall back to a numeric `_2`, `_3`, … suffix. + fn hoist_inline_string_enum( + &mut self, + schema: &Schema, + enum_values: Vec, + primary_name: String, + dependencies: &mut HashSet, + ) -> SchemaType { + fn matches_values(existing: &AnalyzedSchema, values: &[String]) -> bool { + matches!( + &existing.schema_type, + SchemaType::StringEnum { values: existing_values } + if existing_values == values + ) + } + + let mut enum_type_name = primary_name.clone(); + let should_insert = match self.resolved_cache.get(&enum_type_name) { + None => true, + Some(existing) if matches_values(existing, &enum_values) => false, + Some(_) => { + // Collision with different values — try a + // value-suffixed name first. + let suffix = enum_values + .first() + .map(|v| self.to_pascal_case(v)) + .unwrap_or_else(|| "Variant".to_string()); + let candidate = format!("{primary_name}{suffix}"); + + let resolved = match self.resolved_cache.get(&candidate) { + None => Some((candidate.clone(), true)), + Some(existing) if matches_values(existing, &enum_values) => { + Some((candidate.clone(), false)) + } + Some(_) => { + // Walk a numeric suffix until we find + // a slot that's free or matches. + let mut found = None; + for n in 2..1000 { + let numbered = format!("{candidate}_{n}"); + match self.resolved_cache.get(&numbered) { + None => { + found = Some((numbered, true)); + break; + } + Some(existing) if matches_values(existing, &enum_values) => { + found = Some((numbered, false)); + break; + } + Some(_) => continue, + } + } + found + } + }; + + let (resolved_name, insert) = resolved.unwrap_or((candidate, true)); + enum_type_name = resolved_name; + insert + } + }; + + // Store the enum as a named schema if this is the + // first time we've seen this exact (name, values) pair. + if should_insert { + self.resolved_cache.insert( + enum_type_name.clone(), + AnalyzedSchema { + name: enum_type_name.clone(), + original: serde_json::to_value(schema).unwrap_or(Value::Null), + schema_type: SchemaType::StringEnum { + values: enum_values, + }, + dependencies: HashSet::new(), + nullable: false, + description: schema.details().description.clone(), + default: schema.details().default.clone(), + }, + ); + } + + // Return a reference to the named enum type + dependencies.insert(enum_type_name.clone()); + SchemaType::Reference { + target: enum_type_name, + } + } + fn analyze_array_schema( &mut self, schema: &Schema, @@ -3526,10 +3540,27 @@ impl SchemaAnalyzer { Schema::Typed { schema_type, .. } => { // Array of primitive types match schema_type { - OpenApiSchemaType::String => SchemaType::Primitive { - rust_type: "String".to_string(), - serde_with: None, - }, + OpenApiSchemaType::String => { + // Inline string enum in array items — hoist to a + // named enum (`{Parent}Item`) instead of collapsing + // to `Vec`. + match items_schema + .details() + .string_enum_values() + .filter(|values| !values.is_empty()) + { + Some(values) => self.hoist_inline_string_enum( + items_schema, + values, + format!("{parent_schema_name}Item"), + dependencies, + ), + None => 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); @@ -3646,10 +3677,26 @@ impl SchemaAnalyzer { target: object_type_name, } } - OpenApiSchemaType::String => SchemaType::Primitive { - rust_type: "String".to_string(), - serde_with: None, - }, + OpenApiSchemaType::String => { + // Typeless (OpenAPI 3.1) enum in array items — + // same hoisting as the typed-string arm. + match items_schema + .details() + .string_enum_values() + .filter(|values| !values.is_empty()) + { + Some(values) => self.hoist_inline_string_enum( + items_schema, + values, + format!("{parent_schema_name}Item"), + dependencies, + ), + None => 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); diff --git a/src/snapshots/openapi_to_rust__test_helpers__array_item_enum_disambiguation.snap b/src/snapshots/openapi_to_rust__test_helpers__array_item_enum_disambiguation.snap new file mode 100644 index 0000000..9ea94be --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__array_item_enum_disambiguation.snap @@ -0,0 +1,77 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Outer { + #[serde(skip_serializing_if = "Option::is_none")] + pub nested: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum OuterTagsItemRED { + #[default] + #[serde(rename = "RED")] + Red, + #[serde(rename = "BLUE")] + Blue, +} +impl OuterTagsItemRED { + pub fn as_str(&self) -> &'static str { + match self { + Self::Red => "RED", + Self::Blue => "BLUE", + } + } +} +impl ::std::fmt::Display for OuterTagsItemRED { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } +} +impl AsRef for OuterTagsItemRED { + fn as_ref(&self) -> &str { + self.as_str() + } +} +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct OuterNested { + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum OuterTagsItem { + #[default] + #[serde(rename = "HOT")] + Hot, + #[serde(rename = "COLD")] + Cold, +} +impl OuterTagsItem { + pub fn as_str(&self) -> &'static str { + match self { + Self::Hot => "HOT", + Self::Cold => "COLD", + } + } +} +impl ::std::fmt::Display for OuterTagsItem { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } +} +impl AsRef for OuterTagsItem { + fn as_ref(&self) -> &str { + self.as_str() + } +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__array_item_string_enum.snap b/src/snapshots/openapi_to_rust__test_helpers__array_item_string_enum.snap new file mode 100644 index 0000000..9c91a97 --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__array_item_string_enum.snap @@ -0,0 +1,44 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Profile { + #[serde(skip_serializing_if = "Option::is_none")] + pub languages: Option>, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum ProfileLanguagesItem { + #[default] + #[serde(rename = "EN")] + En, + #[serde(rename = "ES")] + Es, +} +impl ProfileLanguagesItem { + pub fn as_str(&self) -> &'static str { + match self { + Self::En => "EN", + Self::Es => "ES", + } + } +} +impl ::std::fmt::Display for ProfileLanguagesItem { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } +} +impl AsRef for ProfileLanguagesItem { + fn as_ref(&self) -> &str { + self.as_str() + } +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__nullable_anyof_array_item_enum.snap b/src/snapshots/openapi_to_rust__test_helpers__nullable_anyof_array_item_enum.snap new file mode 100644 index 0000000..9ff082d --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__nullable_anyof_array_item_enum.snap @@ -0,0 +1,44 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct GetProfileResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub languages: Option>, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum GetProfileResponseLanguagesItem { + #[default] + #[serde(rename = "EN")] + En, + #[serde(rename = "ES")] + Es, +} +impl GetProfileResponseLanguagesItem { + pub fn as_str(&self) -> &'static str { + match self { + Self::En => "EN", + Self::Es => "ES", + } + } +} +impl ::std::fmt::Display for GetProfileResponseLanguagesItem { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } +} +impl AsRef for GetProfileResponseLanguagesItem { + fn as_ref(&self) -> &str { + self.as_str() + } +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__plain_string_array_items.snap b/src/snapshots/openapi_to_rust__test_helpers__plain_string_array_items.snap new file mode 100644 index 0000000..fcee653 --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__plain_string_array_items.snap @@ -0,0 +1,18 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Doc { + #[serde(skip_serializing_if = "Option::is_none")] + pub lines: Option>, +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__typeless_array_item_enum.snap b/src/snapshots/openapi_to_rust__test_helpers__typeless_array_item_enum.snap new file mode 100644 index 0000000..0ed6c55 --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__typeless_array_item_enum.snap @@ -0,0 +1,47 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Widget { + #[serde(skip_serializing_if = "Option::is_none")] + pub sizes: Option>, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum WidgetSizesItem { + #[default] + #[serde(rename = "S")] + S, + #[serde(rename = "M")] + M, + #[serde(rename = "L")] + L, +} +impl WidgetSizesItem { + pub fn as_str(&self) -> &'static str { + match self { + Self::S => "S", + Self::M => "M", + Self::L => "L", + } + } +} +impl ::std::fmt::Display for WidgetSizesItem { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } +} +impl AsRef for WidgetSizesItem { + fn as_ref(&self) -> &str { + self.as_str() + } +} diff --git a/tests/array_item_enum_test.rs b/tests/array_item_enum_test.rs new file mode 100644 index 0000000..76cc757 --- /dev/null +++ b/tests/array_item_enum_test.rs @@ -0,0 +1,221 @@ +#![cfg(feature = "test-helpers")] + +//! Tests for inline string enums in array items (gh#33). +//! +//! Array items with `type: string` + `enum` must hoist to a named enum +//! (`{Parent}Item`) instead of collapsing to `Vec`, matching the +//! existing hoisting behavior for property-level inline enums. + +use openapi_to_rust::test_helpers::*; +use serde_json::json; + +#[test] +fn test_array_item_string_enum_is_hoisted() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0"}, + "components": { + "schemas": { + "Profile": { + "type": "object", + "properties": { + "languages": { + "type": "array", + "items": { + "type": "string", + "enum": ["EN", "ES"] + } + } + } + } + } + } + }); + + let result = test_generation("array_item_string_enum", spec).expect("Generation failed"); + + assert!( + result.contains("pub enum ProfileLanguagesItem"), + "Should generate a named enum for array items, got:\n{result}" + ); + assert!( + result.contains("pub languages: Option>"), + "Array field should use the generated enum type, got:\n{result}" + ); + assert!( + !result.contains("pub languages: Option>"), + "Array field should NOT collapse to Vec" + ); +} + +#[test] +fn test_nullable_anyof_array_item_enum_is_hoisted() { + // Exact repro from gh#33: anyOf [array-of-enum, null] on an inline + // response schema. + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "repro", "version": "1.0.0"}, + "paths": { + "/profile": { + "get": { + "operationId": "getProfile", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "languages": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "enum": ["EN", "ES"] + } + }, + {"type": "null"} + ] + } + } + } + } + } + } + } + } + } + } + }); + + let result = + test_generation("nullable_anyof_array_item_enum", spec).expect("Generation failed"); + + assert!( + result.contains("pub enum GetProfileResponseLanguagesItem"), + "Should generate a named enum for nullable array items, got:\n{result}" + ); + assert!( + result.contains("pub languages: Option>"), + "Nullable array field should use the generated enum type, got:\n{result}" + ); + assert!( + result.contains("#[serde(rename = \"EN\")]") + && result.contains("#[serde(rename = \"ES\")]"), + "Enum variants should rename to the spec values, got:\n{result}" + ); +} + +#[test] +fn test_typeless_array_item_enum_is_hoisted() { + // OpenAPI 3.1 allows enum without an explicit `type`; the item type is + // inferred as string and must hoist the same way. + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0"}, + "components": { + "schemas": { + "Widget": { + "type": "object", + "properties": { + "sizes": { + "type": "array", + "items": { + "enum": ["S", "M", "L"] + } + } + } + } + } + } + }); + + let result = test_generation("typeless_array_item_enum", spec).expect("Generation failed"); + + assert!( + result.contains("pub enum WidgetSizesItem"), + "Should generate a named enum for typeless enum array items, got:\n{result}" + ); + assert!( + result.contains("pub sizes: Option>"), + "Array field should use the generated enum type, got:\n{result}" + ); +} + +#[test] +fn test_same_named_array_item_enums_with_different_values_disambiguate() { + // Two schemas whose array-item enums land on different names keep their + // own variants; a recurring property name with different values must not + // silently overwrite the first registration. + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0"}, + "components": { + "schemas": { + "Outer": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string", "enum": ["RED", "BLUE"]} + }, + "nested": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string", "enum": ["HOT", "COLD"]} + } + } + } + } + } + } + } + }); + + let result = + test_generation("array_item_enum_disambiguation", spec).expect("Generation failed"); + + for value in ["RED", "BLUE", "HOT", "COLD"] { + assert!( + result.contains(&format!("#[serde(rename = \"{value}\")]")), + "Both enums should survive with all variants; missing {value}:\n{result}" + ); + } + assert!( + !result.contains("Vec"), + "Neither array should collapse to Vec, got:\n{result}" + ); +} + +#[test] +fn test_plain_string_array_items_stay_string() { + // No enum on the items — behavior must not change. + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0"}, + "components": { + "schemas": { + "Doc": { + "type": "object", + "properties": { + "lines": { + "type": "array", + "items": {"type": "string"} + } + } + } + } + } + }); + + let result = test_generation("plain_string_array_items", spec).expect("Generation failed"); + + assert!( + result.contains("pub lines: Option>"), + "Plain string arrays should stay Vec, got:\n{result}" + ); +} From 1a68de77472e7fa1d821efe12f6b102651f32319 Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 17 Jul 2026 08:53:08 -0600 Subject: [PATCH 2/2] chore: sync beads metadata Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 20a05ba..f828e43 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"openapi-generator-554","title":"Array items with inline string enum generate Vec\u003cString\u003e instead of named enum (gh#33)","description":"GitHub issue gpu-cli/openapi-to-rust#33: analyze_array_schema maps string array items straight to String, ignoring enum values, while analyze_property_schema_with_context hoists inline string enums into named types. Fix: extract the enum-hoisting logic into a shared helper and use it for array items too.","status":"open","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-17T14:40:53Z","created_by":"James Lal","updated_at":"2026-07-17T14:40:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-554","title":"Array items with inline string enum generate Vec\u003cString\u003e instead of named enum (gh#33)","description":"GitHub issue gpu-cli/openapi-to-rust#33: analyze_array_schema maps string array items straight to String, ignoring enum values, while analyze_property_schema_with_context hoists inline string enums into named types. Fix: extract the enum-hoisting logic into a shared helper and use it for array items too.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T14:40:53Z","created_by":"James Lal","updated_at":"2026-07-17T14:52:45Z","started_at":"2026-07-17T14:41:15Z","closed_at":"2026-07-17T14:52:45Z","close_reason":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/34 (fixes gh#33): array items with inline string enums now hoist to named {Parent}Item enums via shared hoist_inline_string_enum helper","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dj7","title":"Distill and harden developer-first homepage","notes":"Rewrote homepage from 1,007 to 603 visible words (40.1% reduction) and 10 to 6 sections. Hero now states exact output and two-command trial; CI proof separates active PR tier from configured scheduled/manual full-corpus tier; real model/client/server artifacts are linked; repeated eyebrow/grid/window-dot patterns removed; spacing and z-index tokens added; copy failure is visible; neutral background replaces decorative cream grid. Impeccable critique trend 30→36→38 with 0 P0/P1. Gates: Astro 0 diagnostics; 9 pages built; html-validate pass; SEO audit pass; detector and layout detector clean. Playwright MCP and isolated critique contexts verified 1440x1000, 768x1024, 390x844: no page/section/H1 overflow, no console errors, copy/menu/FAQ/focus/touch targets pass. Production deployment dpl_9qhn2wtuiYUYuPsSdduALvGUSicu READY and aliased to https://openapi-to-rust.dev; live desktop/mobile Playwright plus canonical/robots/sitemap/security-header/404 checks pass.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:53:05Z","created_by":"James Lal","updated_at":"2026-07-16T05:33:51Z","started_at":"2026-07-16T04:53:11Z","closed_at":"2026-07-16T05:33:51Z","close_reason":"Developer-first homepage meets all stopping criteria, is deployed, visually verified, and scores 38/40 with no P0/P1 findings.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-gl9","title":"Build and deploy SEO-first Astro documentation site","description":"Create an Astro landing page and documentation site for openapi-to-rust, targeting Rust OpenAPI code-generation search intent; include strong technical SEO, accessible responsive design, local QA, and Vercel deployment.","design":"Host the site in a self-contained website directory so Rust crate workflows remain isolated. Favor static HTML, minimal JavaScript, content-rich documentation, honest comparisons, and code-native visuals.","acceptance_criteria":"Astro site builds successfully; landing page targets openapi generator rust and adjacent intent; metadata, canonical URLs, sitemap, robots, structured data, and social cards are present; responsive and accessible UI is browser-tested and critiqued; deployment succeeds on Vercel; all changes are committed and pushed.","notes":"Implemented an SEO-first Astro site under website/ with 8 indexable routes plus a custom 404; canonical origin https://openapi-to-rust.dev; deployed to Vercel project lbl-rd/openapi-to-rust; Git integration root is website; custom domain verified. Validation: remote Vercel build clean, Astro check clean, SEO audit passed for 9 rendered pages, html-validate passed, live HTTP/canonical/sitemap/security-header/404 checks passed. Playwright browser bridge was present but exposed zero browser backends, so visual testing used independent source/rendered-HTML critique and OG image inspection rather than screenshots.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:03:33Z","created_by":"James Lal","updated_at":"2026-07-16T04:34:21Z","started_at":"2026-07-16T04:03:57Z","closed_at":"2026-07-16T04:34:21Z","close_reason":"SEO site implemented, critiqued, validated, deployed, and connected to the custom domain and Git repository.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-1sb.9","title":"Fix cargo install packaging and generator dependency bloat","description":"Make cargo install --locked install only openapi-to-rust, add --version, align reqwest 0.12, remove emitted-token-only runtime dependencies, gate test helpers, trim features, and add install and dependency-tree regression coverage.","acceptance_criteria":"Only the public CLI is installed; --version works; default dependency tree has no reqwest 0.11; test-only crates are absent from the installed product; temporary-root install smoke performs minimal generation.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:32:25Z","created_by":"James Lal","updated_at":"2026-07-15T14:32:58Z","closed_at":"2026-07-15T14:32:58Z","labels":["accessibility","adoption","cli","dependencies","packaging"],"dependencies":[{"issue_id":"openapi-generator-1sb.9","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:32:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.9","depends_on_id":"openapi-generator-1sb.8","type":"supersedes","created_at":"2026-07-15T08:32:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}