Skip to main content

openapi_to_rust/
analysis.rs

1use crate::openapi::{Discriminator, OpenApiSpec, Schema, SchemaType as OpenApiSchemaType};
2use crate::type_mapping::TypeMapper;
3use crate::{GeneratorError, Result};
4use serde_json::Value;
5use std::collections::{BTreeMap, HashSet};
6use std::path::Path;
7
8/// Q2.6 — pull `x-enum-varnames` / `x-enum-descriptions` arrays off
9/// the schema's original JSON. Both extensions must be string arrays
10/// matching the enum-value count; mismatched extensions are dropped
11/// with a stderr warning so they can't subtly break codegen.
12///
13/// Returns `None` when neither extension is present.
14fn extract_enum_extensions(
15    original: &Value,
16    enum_value_count: usize,
17    schema_name: &str,
18) -> Option<EnumExtensions> {
19    let obj = original.as_object()?;
20
21    let read_string_array = |key: &str| -> Option<Vec<String>> {
22        let arr = obj.get(key)?.as_array()?;
23        let mut out = Vec::with_capacity(arr.len());
24        for v in arr {
25            out.push(v.as_str()?.to_string());
26        }
27        Some(out)
28    };
29
30    let varnames_raw = read_string_array("x-enum-varnames");
31    let descriptions_raw = read_string_array("x-enum-descriptions");
32
33    if varnames_raw.is_none() && descriptions_raw.is_none() {
34        return None;
35    }
36
37    let validate = |label: &str, vals: Option<Vec<String>>| -> Vec<String> {
38        let Some(vals) = vals else {
39            return Vec::new();
40        };
41        if vals.len() == enum_value_count {
42            vals
43        } else {
44            eprintln!(
45                "⚠️  {schema_name}: dropping {label} (expected {enum_value_count} entries, got {})",
46                vals.len()
47            );
48            Vec::new()
49        }
50    };
51
52    let varnames = validate("x-enum-varnames", varnames_raw);
53    let descriptions = validate("x-enum-descriptions", descriptions_raw);
54
55    if varnames.is_empty() && descriptions.is_empty() {
56        return None;
57    }
58    Some(EnumExtensions {
59        varnames,
60        descriptions,
61    })
62}
63
64#[derive(Debug, Clone)]
65pub struct SchemaAnalysis {
66    /// All schemas indexed by name
67    pub schemas: BTreeMap<String, AnalyzedSchema>,
68    /// Dependency graph for generation ordering
69    pub dependencies: DependencyGraph,
70    /// Detected patterns and transformations
71    pub patterns: DetectedPatterns,
72    /// OpenAPI operations and their request/response schemas
73    pub operations: BTreeMap<String, OperationInfo>,
74    /// Complete response contracts by emitted operation ID and response key.
75    /// Unlike `OperationInfo::response_schemas`, this retains responses with
76    /// no body as well as their selected JSON media type and SSE declaration.
77    pub operation_responses: BTreeMap<String, BTreeMap<String, OperationResponse>>,
78    /// Source operationId to emitted operation IDs. Duplicate or
79    /// Rust-identifier-colliding IDs are renamed during analysis; retaining
80    /// this mapping lets selector resolution report ambiguity or renaming.
81    pub operation_id_aliases: BTreeMap<String, Vec<String>>,
82    /// Optional crates the [`TypeMapper`] was asked to reference
83    /// during analysis (e.g. chrono when a `format: date-time` field
84    /// became `chrono::DateTime<Utc>`). The generator reads this to
85    /// decide which helper modules (e.g. `base64_serde`) to emit. Complete
86    /// dependency reporting is collected from retained emitted files so
87    /// pruned schemas cannot leak stale requirements.
88    ///
89    /// [`TypeMapper`]: crate::type_mapping::TypeMapper
90    pub used_type_features: crate::type_mapping::UsedFeatures,
91    /// Q2.6: per-schema vendor enum extensions
92    /// (`x-enum-varnames` / `x-enum-descriptions`). Populated during
93    /// analysis when a StringEnum / ExtensibleEnum schema declares
94    /// either extension; the generator uses these to override the
95    /// default heuristic variant names and emit per-variant doc
96    /// comments. Indexed by analyzed-schema name. Side-channel so we
97    /// don't have to touch every StringEnum constructor.
98    pub enum_extensions: BTreeMap<String, EnumExtensions>,
99    /// Raw, unpruned schema material used to build offline server validators.
100    /// This is deliberately independent of `schemas`, which model pruning may
101    /// mutate before server artifacts are emitted.
102    pub validation_context: ValidationContext,
103}
104
105/// Server-relevant semantics of one OpenAPI Response Object.
106#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
107pub struct OperationResponse {
108    /// Generated Rust body type for the preferred JSON-compatible content.
109    pub schema_name: Option<String>,
110    /// Exact declared JSON-compatible media type selected for `schema_name`.
111    pub media_type: Option<String>,
112    /// Preferred buffered response representation for this status. JSON keeps
113    /// its generated schema name; text and binary bodies are represented
114    /// directly by the generated client/server runtime types.
115    pub body: Option<OperationResponseBody>,
116    /// Whether this response also declares `text/event-stream` content.
117    pub supports_streaming: bool,
118    /// Whether the Response Object declared at least one content entry.
119    pub has_content: bool,
120    /// Declared response media types the server generator cannot emit.
121    pub unsupported_media_types: Vec<String>,
122}
123
124/// Buffered response representation selected from one OpenAPI Response Object.
125/// SSE remains orthogonal on [`OperationResponse::supports_streaming`] because
126/// a response may advertise both a buffered JSON representation and an event
127/// stream.
128#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
129#[serde(tag = "kind", rename_all = "snake_case")]
130pub enum OperationResponseBody {
131    Json {
132        schema_name: String,
133        media_type: String,
134    },
135    Text {
136        media_type: String,
137    },
138    Binary {
139        media_type: String,
140        wildcard: bool,
141    },
142}
143
144#[derive(Debug, Clone, Default)]
145pub struct ValidationContext {
146    pub openapi_version: String,
147    pub json_schema_dialect: Option<String>,
148    pub component_schemas: BTreeMap<String, Value>,
149}
150
151/// Q2.6 — vendor extensions describing a string enum's variant
152/// names and per-variant descriptions. Length must match the
153/// schema's `enum` array; mismatched extensions are dropped at
154/// analysis time with a warning.
155#[derive(Debug, Clone, Default)]
156pub struct EnumExtensions {
157    /// `x-enum-varnames`: Rust-friendly variant identifiers per
158    /// enum value, in the same order as the spec's `enum` array.
159    /// When present and length matches, the generator uses these
160    /// instead of its default PascalCase heuristic.
161    pub varnames: Vec<String>,
162    /// `x-enum-descriptions`: one doc-comment per enum value.
163    pub descriptions: Vec<String>,
164}
165
166#[derive(Debug, Clone)]
167pub struct AnalyzedSchema {
168    pub name: String,
169    pub original: Value,
170    pub schema_type: SchemaType,
171    pub dependencies: HashSet<String>,
172    pub nullable: bool,
173    pub description: Option<String>,
174    pub default: Option<serde_json::Value>,
175}
176
177#[derive(Debug, Clone)]
178pub enum SchemaType {
179    /// Simple primitive type. `serde_with` carries an optional
180    /// `#[serde(with = "<path>")]` codec hint produced by the
181    /// TypeMapper for typed scalars (e.g. `format: byte` →
182    /// `Vec<u8>` + `base64_serde`); the generator wraps this in a
183    /// field-level `with = ...` attribute.
184    Primitive {
185        rust_type: String,
186        serde_with: Option<String>,
187    },
188    /// Object with properties
189    Object {
190        properties: BTreeMap<String, PropertyInfo>,
191        required: HashSet<String>,
192        additional_properties: ObjectAdditionalProperties,
193    },
194    /// Discriminated union (oneOf + discriminator)
195    DiscriminatedUnion {
196        discriminator_field: String,
197        variants: Vec<UnionVariant>,
198    },
199    /// Simple union (anyOf without discriminator)
200    Union { variants: Vec<SchemaRef> },
201    /// Array type
202    Array { item_type: Box<SchemaType> },
203    /// String enum
204    StringEnum { values: Vec<String> },
205    /// Extensible enum with known values and custom variant
206    ExtensibleEnum { known_values: Vec<String> },
207    /// Schema composition (allOf)
208    Composition { schemas: Vec<SchemaRef> },
209    /// Reference to another schema
210    Reference { target: String },
211}
212
213/// How an Object handles `additionalProperties`. Q2.3 split the
214/// pre-existing `bool` into a three-way enum so the generator can
215/// emit a typed `BTreeMap<String, T>` when the spec provides a
216/// value-type schema instead of degrading to `serde_json::Value`.
217#[derive(Debug, Clone)]
218pub enum ObjectAdditionalProperties {
219    /// `additionalProperties: false` or absent — extra keys are
220    /// rejected and no extra field is emitted.
221    Forbidden,
222    /// `additionalProperties: true` — extra keys captured as
223    /// `BTreeMap<String, serde_json::Value>`.
224    Untyped,
225    /// `additionalProperties: <schema>` — extra keys captured as
226    /// `BTreeMap<String, T>` where T comes from the schema.
227    Typed { value_type: Box<SchemaType> },
228}
229
230impl ObjectAdditionalProperties {
231    /// True when extra keys are accepted (regardless of typing).
232    /// Used by callers that only care whether the field exists.
233    pub fn is_open(&self) -> bool {
234        !matches!(self, Self::Forbidden)
235    }
236}
237
238#[derive(Debug, Clone)]
239pub struct PropertyInfo {
240    pub schema_type: SchemaType,
241    pub nullable: bool,
242    pub description: Option<String>,
243    pub default: Option<serde_json::Value>,
244    pub serde_attrs: Vec<String>,
245    /// Q2.4: OpenAPI constraint annotations captured from the
246    /// property schema. Surfaced by the generator as `/// Constraint:
247    /// …` doc lines and/or `#[validate(...)]` attributes depending on
248    /// `[generator.types.constraints] mode`.
249    pub constraints: PropertyConstraints,
250}
251
252/// Q2.4 — per-property OpenAPI constraint annotations
253/// (`minimum`/`maximum`/`minLength`/`maxLength`/`pattern`/etc.).
254/// Populated during analysis from `SchemaDetails`; consumed by the
255/// generator to emit doc comments and/or `#[validate(...)]` attrs.
256#[derive(Debug, Clone, Default)]
257pub struct PropertyConstraints {
258    pub minimum: Option<f64>,
259    pub maximum: Option<f64>,
260    pub exclusive_minimum: Option<f64>,
261    pub exclusive_maximum: Option<f64>,
262    pub multiple_of: Option<f64>,
263    pub min_length: Option<u64>,
264    pub max_length: Option<u64>,
265    pub pattern: Option<String>,
266    pub min_items: Option<u64>,
267    pub max_items: Option<u64>,
268    pub unique_items: Option<bool>,
269}
270
271impl PropertyConstraints {
272    pub fn is_empty(&self) -> bool {
273        self.minimum.is_none()
274            && self.maximum.is_none()
275            && self.exclusive_minimum.is_none()
276            && self.exclusive_maximum.is_none()
277            && self.multiple_of.is_none()
278            && self.min_length.is_none()
279            && self.max_length.is_none()
280            && self.pattern.is_none()
281            && self.min_items.is_none()
282            && self.max_items.is_none()
283            && self.unique_items.is_none()
284    }
285
286    /// Capture the constraint-related fields off a `SchemaDetails`.
287    /// Exclusive bounds in OpenAPI 3.1 are numeric (`exclusiveMinimum:
288    /// 5`); we map the OAS-3.0 boolean flag form by leaving the
289    /// exclusive field unset and letting `minimum`/`maximum` carry it.
290    pub fn from_schema_details(details: &crate::openapi::SchemaDetails) -> Self {
291        use crate::openapi::ExclusiveBound;
292        let exclusive_minimum = match &details.exclusive_minimum {
293            Some(ExclusiveBound::Number(v)) => Some(*v),
294            _ => None,
295        };
296        let exclusive_maximum = match &details.exclusive_maximum {
297            Some(ExclusiveBound::Number(v)) => Some(*v),
298            _ => None,
299        };
300        Self {
301            minimum: details.minimum,
302            maximum: details.maximum,
303            exclusive_minimum,
304            exclusive_maximum,
305            multiple_of: details.multiple_of,
306            min_length: details.min_length,
307            max_length: details.max_length,
308            pattern: details.pattern.clone(),
309            min_items: details.min_items,
310            max_items: details.max_items,
311            unique_items: details.unique_items,
312        }
313    }
314}
315
316#[derive(Debug, Clone)]
317pub struct UnionVariant {
318    pub rust_name: String,
319    pub type_name: String,
320    pub discriminator_value: String,
321    pub schema_ref: String,
322}
323
324#[derive(Debug, Clone)]
325pub struct SchemaRef {
326    pub target: String,
327    pub nullable: bool,
328}
329
330#[derive(Debug, Clone)]
331pub struct DependencyGraph {
332    pub edges: BTreeMap<String, HashSet<String>>,
333    /// Set of schemas that have recursive dependencies
334    pub recursive_schemas: HashSet<String>,
335}
336
337#[derive(Debug, Clone)]
338pub struct DetectedPatterns {
339    /// Schemas that should use tagged enums (discriminated unions)
340    pub tagged_enum_schemas: HashSet<String>,
341    /// Schemas that should use untagged enums (simple unions)  
342    pub untagged_enum_schemas: HashSet<String>,
343    /// Auto-detected type mappings for discriminated unions
344    pub type_mappings: BTreeMap<String, BTreeMap<String, String>>,
345}
346
347/// Information about an OpenAPI operation
348#[derive(Debug, Clone, Default, serde::Serialize)]
349pub struct OperationInfo {
350    /// Operation ID
351    pub operation_id: String,
352    /// HTTP method (GET, POST, etc.)
353    pub method: String,
354    /// Path template
355    pub path: String,
356    /// Short summary from OpenAPI spec
357    pub summary: Option<String>,
358    /// Longer description from OpenAPI spec
359    pub description: Option<String>,
360    /// Request body content type and schema (if any)
361    pub request_body: Option<RequestBodyContent>,
362    /// Whether `requestBody.required` was true. Drives whether the generated
363    /// method takes a `Body` argument or `Option<Body>` (T11).
364    pub request_body_required: bool,
365    /// Response schemas by status code
366    pub response_schemas: BTreeMap<String, String>,
367    /// Parameters (path, query, header)
368    pub parameters: Vec<ParameterInfo>,
369    /// Whether this operation supports streaming
370    pub supports_streaming: bool,
371    /// Stream parameter name if applicable
372    pub stream_parameter: Option<String>,
373    /// Tags declared on the operation. Empty when the spec sets none.
374    /// Used by the server codegen selector grammar (e.g. `tag:Chat`)
375    /// and by `openapi-to-rust server list` for grouping.
376    pub tags: Vec<String>,
377}
378
379/// Content type and schema for a request body
380#[derive(Debug, Clone, serde::Serialize)]
381#[serde(tag = "kind")]
382pub enum RequestBodyContent {
383    Json {
384        schema_name: String,
385        media_type: String,
386        #[serde(skip)]
387        validation_schema: Value,
388    },
389    FormUrlEncoded {
390        schema_name: String,
391        media_type: String,
392        #[serde(skip)]
393        validation_schema: Value,
394    },
395    Multipart {
396        schema_name: String,
397        media_type: String,
398        #[serde(skip)]
399        validation_schema: Value,
400    },
401    OctetStream {
402        media_type: String,
403    },
404    Binary {
405        media_type: String,
406    },
407    TextPlain {
408        media_type: String,
409    },
410    /// A declared request media type without a schema. Client generation
411    /// preserves its historical no-body signature, while server generation
412    /// rejects the operation because there is no contract to validate.
413    SchemaLess {
414        media_type: String,
415    },
416    Unsupported {
417        media_types: Vec<String>,
418    },
419}
420
421impl RequestBodyContent {
422    /// Get the schema name if this content type has one
423    pub fn schema_name(&self) -> Option<&str> {
424        match self {
425            Self::Json { schema_name, .. }
426            | Self::FormUrlEncoded { schema_name, .. }
427            | Self::Multipart { schema_name, .. } => Some(schema_name),
428            Self::OctetStream { .. }
429            | Self::Binary { .. }
430            | Self::TextPlain { .. }
431            | Self::SchemaLess { .. }
432            | Self::Unsupported { .. } => None,
433        }
434    }
435}
436
437/// Compute the disambiguation-base for a parameter name. Mirrors
438/// `ClientGenerator::sanitize_param_name` so analysis-time uniqueness
439/// decisions and codegen-time emission agree on the final ident.
440fn base_param_ident(name: &str) -> String {
441    use heck::ToSnakeCase;
442    let suffix = if name.ends_with("<=") {
443        "_lte"
444    } else if name.ends_with(">=") {
445        "_gte"
446    } else if name.ends_with('<') {
447        "_lt"
448    } else if name.ends_with('>') {
449        "_gt"
450    } else {
451        ""
452    };
453    let stripped = name.trim_end_matches(['<', '>', '=']);
454    let mut snake = stripped.to_snake_case();
455    if snake.is_empty() {
456        snake.push_str("parameter");
457    } else if snake.starts_with(|character: char| character.is_ascii_digit()) {
458        snake.insert(0, '_');
459    }
460    snake.push_str(suffix);
461    snake
462}
463
464/// Information about an operation parameter
465#[derive(Debug, Clone, serde::Serialize)]
466pub struct ParameterInfo {
467    /// Parameter name
468    pub name: String,
469    /// Parameter location (path, query, header, cookie)
470    pub location: String,
471    /// Whether the parameter is required
472    pub required: bool,
473    /// Schema reference for the parameter type
474    pub schema_ref: Option<String>,
475    /// Rust type for this parameter
476    pub rust_type: String,
477    /// Description from OpenAPI spec
478    pub description: Option<String>,
479    /// String enum values when the parameter's inline schema is a string with
480    /// `enum` or `const`. When set, `rust_type` is the synthetic enum type
481    /// name (e.g. `GetItemTheConstant`) and the client generator emits an
482    /// inline enum so the parameter is constrained to the declared values.
483    /// See issue #10 follow-up.
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub enum_values: Option<Vec<String>>,
486    /// `x-enum-varnames` declared on the parameter's inline enum schema, when
487    /// present and the same length as `enum_values`. Schema-level enums already
488    /// honor this vendor extension through `SchemaAnalysis::enum_extensions`;
489    /// parameter enums are inline and have no analyzed-schema name to key on,
490    /// so their names ride along here instead.
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub enum_varnames: Option<Vec<String>>,
493    /// Disambiguated Rust ident assigned by the analyzer at the operation
494    /// scope. When two parameters in the same operation sanitize to the same
495    /// snake_case name (e.g. `exclude_ids` + `exclude-ids` in vercel,
496    /// `StartTime` + `StartTime>` in twilio), the analyzer suffixes
497    /// later occurrences with `_2`, `_3`, … so the codegen function
498    /// signature and body don't reuse the same binding.
499    /// Empty/none = use sanitize from `name`.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub rust_ident: Option<String>,
502    /// Wire serialization for object/array query parameters, decided from
503    /// the parameter's `style`/`explode` and schema shape (T14, GH #27).
504    /// `None` = plain single `name=value` pair (scalars, string enums, and
505    /// the ordinary scalar `name=value` representation. Unsupported complex
506    /// shapes carry an explicit [`QuerySerialization::Unsupported`] reason so
507    /// downstream client/server generators cannot silently drift.
508    /// For the object modes, `schema_ref` holds the struct type
509    /// generated/resolved for the object schema.
510    #[serde(skip_serializing_if = "Option::is_none")]
511    pub query_serialization: Option<QuerySerialization>,
512    /// Original parameter schema retained for request validation. This is not
513    /// exposed by serialized operation listings.
514    #[serde(skip)]
515    pub validation_schema: Option<Value>,
516}
517
518/// How generated clients serialize and generated servers extract an object-
519/// or array-schema query parameter.
520#[derive(Debug, Clone, PartialEq, serde::Serialize)]
521pub enum QuerySerialization {
522    /// style=form + explode=true object (the OAS 3.x defaults for query):
523    /// each property is its own pair — `?color=red&size=big`. The parameter
524    /// name never appears in the query string (RFC 6570 form-explosion).
525    FormExplodedObject,
526    /// AWS query-protocol form explosion for an object containing arrays:
527    /// `Parameter.Prop.1=value` or `Parameter.Prop.1.Leaf=value`. Unlike
528    /// ordinary RFC 6570 form explosion, AWS service models retain the outer
529    /// parameter wire name; client and server generation intentionally mirror
530    /// that protocol-specific representation.
531    FormExplodedNestedObject {
532        properties: Vec<QueryStructProperty>,
533    },
534    /// style=form + explode=false object: one comma-joined key,value list —
535    /// `?filter=color,red,size,big`.
536    FormObject,
537    /// style=deepObject (explode=true) object: bracketed keys —
538    /// `?filter[color]=red`.
539    DeepObject,
540    /// style=form + explode=true array: repeated pairs — `?tags=a&tags=b`.
541    /// Parameter typed `Vec<item_type>`.
542    FormExplodedArray { item_type: ArrayItemType },
543    /// style=form + explode=false array: one comma-joined pair —
544    /// `?tags=a,b,c`. Parameter typed `Vec<item_type>`.
545    FormArray { item_type: ArrayItemType },
546    /// Header `style=simple, explode=false` array: one physical header value
547    /// containing comma-separated scalar items.
548    SimpleHeaderArray { item_type: ArrayItemType },
549    /// A complex query shape whose wire representation is undefined by
550    /// OpenAPI or not implemented symmetrically. Clients retain the explicit
551    /// opaque-string escape hatch; server generation rejects it with this
552    /// actionable reason instead of emitting an impossible extractor.
553    Unsupported { reason: String },
554}
555
556/// Item type of a typed array query parameter. The two variants need
557/// different handling in codegen: scalars are already Rust type strings
558/// (possibly paths like `rust_decimal::Decimal` from `[type_mappings]`),
559/// while schema refs are raw *schema names* that must run through
560/// `to_rust_type_name` sanitization (cloudflare:
561/// `resource-sharing_resource_type`).
562#[derive(Debug, Clone, PartialEq, serde::Serialize)]
563pub enum ArrayItemType {
564    /// A Rust scalar type string from the TypeMapper (`String`, `i32`, …).
565    Scalar(String),
566    /// The schema name of a referenced scalar alias or string enum.
567    SchemaRef(String),
568    /// The schema name of a referenced *flat* structure — every property is
569    /// scalar. Serialized AWS query-protocol style as
570    /// `param.N.Prop=value` per item (e.g. `Tags.1.Key=k&Tags.1.Value=v`).
571    /// Carries the wire property names so client and server emit identical
572    /// keys without re-resolving the schema.
573    FlatStructRef {
574        schema_name: String,
575        properties: Vec<QueryStructProperty>,
576    },
577    /// A referenced structure with scalar properties plus arrays whose items
578    /// are scalar or flat structures. This is the deepest unambiguous shape
579    /// used by AWS query protocols (`param.N.Prop.M.Leaf=value`).
580    NestedStructRef {
581        schema_name: String,
582        properties: Vec<QueryStructProperty>,
583    },
584}
585
586#[derive(Debug, Clone, PartialEq, serde::Serialize)]
587pub struct QueryStructProperty {
588    pub wire_name: String,
589    pub required: bool,
590    pub value_type: QueryStructPropertyType,
591}
592
593#[derive(Debug, Clone, PartialEq, serde::Serialize)]
594pub enum QueryStructPropertyType {
595    Scalar(QueryScalarType),
596    Array {
597        item_type: ArrayItemType,
598    },
599    Object {
600        properties: Vec<QueryStructProperty>,
601    },
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
605pub enum QueryScalarType {
606    String,
607    Integer,
608    Number,
609    Boolean,
610}
611
612impl Default for DependencyGraph {
613    fn default() -> Self {
614        Self::new()
615    }
616}
617
618impl DependencyGraph {
619    pub fn new() -> Self {
620        Self {
621            edges: BTreeMap::new(),
622            recursive_schemas: HashSet::new(),
623        }
624    }
625
626    pub fn add_dependency(&mut self, from: String, to: String) {
627        self.edges.entry(from).or_default().insert(to);
628    }
629
630    /// Get topological sort order for generation
631    pub fn topological_sort(&mut self) -> Result<Vec<String>> {
632        // First, detect and handle recursive dependencies
633        self.detect_recursive_schemas();
634
635        // Create a temporary graph without self-referencing edges for sorting
636        let mut temp_edges = self.edges.clone();
637        for (schema, deps) in &mut temp_edges {
638            deps.remove(schema); // Remove self-references
639        }
640
641        let mut visited = HashSet::new();
642        let mut temp_visited = HashSet::new();
643        let mut result = Vec::new();
644
645        // Visit all nodes using the temporary graph in sorted order for deterministic output
646        let mut all_nodes: Vec<_> = temp_edges.keys().collect();
647        all_nodes.sort();
648        for node in all_nodes {
649            if !visited.contains(node) {
650                self.visit_node_recursive(
651                    node,
652                    &temp_edges,
653                    &mut visited,
654                    &mut temp_visited,
655                    &mut result,
656                )?;
657            }
658        }
659
660        result.reverse();
661        Ok(result)
662    }
663
664    fn detect_recursive_schemas(&mut self) {
665        for (schema, deps) in &self.edges {
666            if deps.contains(schema) {
667                // Direct self-reference
668                self.recursive_schemas.insert(schema.clone());
669            } else {
670                // Check for indirect cycles
671                if self.has_cycle_from(schema, schema, &mut HashSet::new()) {
672                    self.recursive_schemas.insert(schema.clone());
673                }
674            }
675        }
676
677        // Also detect mutual recursion (like GraphNode <-> GraphEdge)
678        for (schema, deps) in &self.edges {
679            for dep in deps {
680                if let Some(dep_deps) = self.edges.get(dep) {
681                    if dep_deps.contains(schema) {
682                        // Mutual recursion detected
683                        self.recursive_schemas.insert(schema.clone());
684                        self.recursive_schemas.insert(dep.clone());
685                    }
686                }
687            }
688        }
689    }
690
691    fn has_cycle_from(&self, start: &str, current: &str, visited: &mut HashSet<String>) -> bool {
692        if visited.contains(current) {
693            return false; // Already checked this path
694        }
695
696        visited.insert(current.to_string());
697
698        if let Some(deps) = self.edges.get(current) {
699            for dep in deps {
700                if dep == start {
701                    return true; // Found cycle back to start
702                }
703                if self.has_cycle_from(start, dep, visited) {
704                    return true;
705                }
706            }
707        }
708
709        false
710    }
711
712    #[allow(clippy::only_used_in_recursion)]
713    fn visit_node_recursive(
714        &self,
715        node: &str,
716        temp_edges: &BTreeMap<String, HashSet<String>>,
717        visited: &mut HashSet<String>,
718        temp_visited: &mut HashSet<String>,
719        result: &mut Vec<String>,
720    ) -> Result<()> {
721        if temp_visited.contains(node) {
722            // This should not happen with cycle-free temp graph, but just in case
723            return Ok(());
724        }
725
726        if visited.contains(node) {
727            return Ok(());
728        }
729
730        temp_visited.insert(node.to_string());
731
732        if let Some(dependencies) = temp_edges.get(node) {
733            // Sort dependencies for deterministic topological order
734            let mut sorted_deps: Vec<_> = dependencies.iter().collect();
735            sorted_deps.sort();
736            for dep in sorted_deps {
737                self.visit_node_recursive(dep, temp_edges, visited, temp_visited, result)?;
738            }
739        }
740
741        temp_visited.remove(node);
742        visited.insert(node.to_string());
743        result.push(node.to_string());
744
745        Ok(())
746    }
747}
748
749/// Merge schema extension files into the main OpenAPI specification
750/// Uses simple recursive JSON object merging
751pub fn merge_schema_extensions(
752    main_spec: Value,
753    extension_paths: &[impl AsRef<Path>],
754) -> Result<Value> {
755    let mut result = main_spec;
756
757    for path in extension_paths {
758        let extension = load_extension_file(path.as_ref())?;
759        result = merge_json_objects_with_replacements(result, extension)?;
760    }
761
762    Ok(result)
763}
764
765/// AWS-style specs append query markers to their path templates
766/// (`/tags/{resourceArn}#tagKeys`, `/2015-02-01/resource-tags/{ResourceId}#tagKeys`).
767/// The fragment is not part of the route — those values are declared as
768/// ordinary query parameters on the operation — so strip it before the path
769/// reaches route generation. Axum (and every HTTP router) matches on the path
770/// component only.
771fn normalize_operation_path(path: &str) -> String {
772    match path.split_once('#') {
773        Some((route, _fragment)) if route.starts_with('/') => route.to_string(),
774        _ => path.to_string(),
775    }
776}
777
778/// See through an `allOf: [$ref, {annotation}]` wrapper around a schema, the
779/// same shape `analyze_all_of` treats as a type alias. Returns the sole
780/// reference target's schema when every other member is annotation-only;
781/// otherwise the schema itself.
782fn unwrap_annotation_allof(schema: &crate::openapi::Schema) -> &crate::openapi::Schema {
783    let crate::openapi::Schema::AllOf { all_of, .. } = schema else {
784        return schema;
785    };
786    let mut references = all_of.iter().filter(|s| s.reference().is_some());
787    let (Some(first), None) = (references.next(), references.next()) else {
788        return schema;
789    };
790    let others_annotation_only = all_of.iter().all(|member| {
791        if member.reference().is_some() {
792            return true;
793        }
794        serde_json::to_value(member)
795            .ok()
796            .and_then(|value| value.as_object().cloned())
797            .is_some_and(|object| {
798                object.keys().all(|key| {
799                    matches!(
800                        key.as_str(),
801                        "title"
802                            | "description"
803                            | "deprecated"
804                            | "readOnly"
805                            | "writeOnly"
806                            | "examples"
807                            | "example"
808                            | "externalDocs"
809                            | "xml"
810                            | "$comment"
811                    ) || key.starts_with("x-")
812                })
813            })
814    });
815    if others_annotation_only {
816        first
817    } else {
818        schema
819    }
820}
821
822/// Load an extension file and parse it into the JSON representation used by
823/// the analyzer. YAML extensions follow the same conversion policy as YAML
824/// OpenAPI documents; every other extension is parsed as JSON.
825fn load_extension_file(path: &Path) -> Result<Value> {
826    let content = std::fs::read_to_string(path).map_err(|e| GeneratorError::FileError {
827        message: format!("Failed to read file {}: {}", path.display(), e),
828    })?;
829
830    let is_yaml = path
831        .extension()
832        .and_then(|extension| extension.to_str())
833        .is_some_and(|extension| {
834            extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml")
835        });
836
837    if is_yaml {
838        crate::spec_source::yaml_to_json_value(&content).map_err(|error| {
839            GeneratorError::FileError {
840                message: format!(
841                    "Failed to parse schema extension {} as YAML: {}",
842                    path.display(),
843                    error
844                ),
845            }
846        })
847    } else {
848        serde_json::from_str(&content).map_err(|error| GeneratorError::FileError {
849            message: format!(
850                "Failed to parse schema extension {} as JSON: {}",
851                path.display(),
852                error
853            ),
854        })
855    }
856}
857
858/// Merge JSON objects with explicit replacement support
859fn merge_json_objects_with_replacements(main: Value, extension: Value) -> Result<Value> {
860    // Extract replacement rules from the extension
861    let replacements = extract_replacement_rules(&extension);
862
863    // Perform the merge with replacement awareness
864    Ok(merge_json_objects_with_rules(
865        main,
866        extension,
867        &replacements,
868    ))
869}
870
871/// Extract x-replacements rules from extension
872fn extract_replacement_rules(
873    extension: &Value,
874) -> std::collections::HashMap<String, (String, String)> {
875    let mut rules = std::collections::HashMap::new();
876
877    if let Some(x_replacements) = extension.get("x-replacements") {
878        if let Some(x_replacements_obj) = x_replacements.as_object() {
879            for (schema_name, replacement_rule) in x_replacements_obj {
880                if let Some(rule_obj) = replacement_rule.as_object() {
881                    if let (Some(replace), Some(with)) = (
882                        rule_obj.get("replace").and_then(|v| v.as_str()),
883                        rule_obj.get("with").and_then(|v| v.as_str()),
884                    ) {
885                        rules.insert(schema_name.clone(), (replace.to_string(), with.to_string()));
886                        // println!("📋 Replacement rule: In {}, replace {} with {}", schema_name, replace, with);
887                    }
888                }
889            }
890        }
891    }
892
893    rules
894}
895
896/// Check if a variant should be replaced based on explicit replacement rules
897fn should_replace_variant(
898    schema_name: &str,
899    extension_refs: &[String],
900    replacements: &std::collections::HashMap<String, (String, String)>,
901) -> bool {
902    // Check all replacement rules
903    for (replace_schema, with_schema) in replacements.values() {
904        if schema_name == replace_schema {
905            // This schema should be replaced - check if the replacement schema is in extensions
906            let replacement_exists = extension_refs.iter().any(|ext_ref| {
907                let ext_schema_name = ext_ref.split('/').next_back().unwrap_or("");
908                ext_schema_name == with_schema
909            });
910
911            if replacement_exists {
912                return true;
913            }
914        }
915    }
916
917    // Fallback to exact name match for complete replacement
918    extension_refs.iter().any(|ext_ref| {
919        let ext_schema_name = ext_ref.split('/').next_back().unwrap_or("");
920        schema_name == ext_schema_name
921    })
922}
923
924/// Recursively merge two JSON values with replacement rules
925/// Objects are merged by combining properties
926/// Arrays are merged by concatenating
927/// Primitives in the extension override the main value
928fn merge_json_objects_with_rules(
929    main: Value,
930    extension: Value,
931    replacements: &std::collections::HashMap<String, (String, String)>,
932) -> Value {
933    match (main, extension) {
934        // Both objects - merge properties
935        (Value::Object(mut main_obj), Value::Object(ext_obj)) => {
936            // Special handling for schema objects with oneOf/anyOf variants.
937            // Detect which keyword the MAIN spec uses so we preserve it after merging.
938            let main_union_keyword = if main_obj.contains_key("oneOf") {
939                Some("oneOf")
940            } else if main_obj.contains_key("anyOf") {
941                Some("anyOf")
942            } else {
943                None
944            };
945            if let (Some(main_variants), Some(ext_variants)) = (
946                extract_schema_variants(&Value::Object(main_obj.clone())),
947                extract_schema_variants(&Value::Object(ext_obj.clone())),
948            ) {
949                let union_key = main_union_keyword.unwrap_or("oneOf");
950                println!(
951                    "🔍 Merging union schemas ({union_key}): {} main variants, {} extension variants",
952                    main_variants.len(),
953                    ext_variants.len()
954                );
955                // Merge the variant arrays, preserving the original union keyword
956                // First, collect main variants, but filter out any that will be replaced by extension
957                let mut merged_variants = Vec::new();
958                let extension_refs: Vec<String> = ext_variants
959                    .iter()
960                    .filter_map(|v| v.get("$ref").and_then(|r| r.as_str()))
961                    .map(|s| s.to_string())
962                    .collect();
963
964                // Add main variants that aren't being replaced
965                for main_variant in main_variants {
966                    if let Some(main_ref) = main_variant.get("$ref").and_then(|r| r.as_str()) {
967                        // Check if this main variant should be replaced by an extension variant
968                        let schema_name = main_ref.split('/').next_back().unwrap_or("");
969                        let should_replace =
970                            should_replace_variant(schema_name, &extension_refs, replacements);
971
972                        if should_replace {
973                            println!("🔄 REPLACING {} (explicit rule)", schema_name);
974                        }
975
976                        if !should_replace {
977                            merged_variants.push(main_variant);
978                        }
979                    } else {
980                        // Keep non-ref variants
981                        merged_variants.push(main_variant);
982                    }
983                }
984
985                // Add all extension variants
986                for ext_variant in ext_variants {
987                    merged_variants.push(ext_variant);
988                }
989
990                // Remove old oneOf/anyOf keys and add merged variants under the original keyword
991                main_obj.remove("oneOf");
992                main_obj.remove("anyOf");
993                main_obj.insert(union_key.to_string(), Value::Array(merged_variants));
994
995                // Merge other properties normally
996                for (key, ext_value) in ext_obj {
997                    if key != "oneOf" && key != "anyOf" {
998                        match main_obj.get(&key) {
999                            Some(main_value) => {
1000                                let merged_value = merge_json_objects_with_rules(
1001                                    main_value.clone(),
1002                                    ext_value,
1003                                    replacements,
1004                                );
1005                                main_obj.insert(key, merged_value);
1006                            }
1007                            None => {
1008                                main_obj.insert(key, ext_value);
1009                            }
1010                        }
1011                    }
1012                }
1013
1014                return Value::Object(main_obj);
1015            }
1016
1017            // Normal object merging
1018            for (key, ext_value) in ext_obj {
1019                match main_obj.get(&key) {
1020                    Some(main_value) => {
1021                        // Key exists in both - recursively merge
1022                        let merged_value = merge_json_objects_with_rules(
1023                            main_value.clone(),
1024                            ext_value,
1025                            replacements,
1026                        );
1027                        main_obj.insert(key, merged_value);
1028                    }
1029                    None => {
1030                        // Key only in extension - add it
1031                        main_obj.insert(key, ext_value);
1032                    }
1033                }
1034            }
1035            Value::Object(main_obj)
1036        }
1037
1038        // Both arrays - concatenate
1039        (Value::Array(mut main_arr), Value::Array(ext_arr)) => {
1040            main_arr.extend(ext_arr);
1041            Value::Array(main_arr)
1042        }
1043
1044        // Extension overrides main for all other cases
1045        (_, extension) => extension,
1046    }
1047}
1048
1049/// Extract schema variants from oneOf or anyOf properties
1050fn extract_schema_variants(obj: &Value) -> Option<Vec<Value>> {
1051    if let Value::Object(map) = obj {
1052        if let Some(Value::Array(variants)) = map.get("oneOf") {
1053            return Some(variants.clone());
1054        }
1055        if let Some(Value::Array(variants)) = map.get("anyOf") {
1056            return Some(variants.clone());
1057        }
1058    }
1059    None
1060}
1061
1062pub struct SchemaAnalyzer {
1063    schemas: BTreeMap<String, Schema>,
1064    resolved_cache: BTreeMap<String, AnalyzedSchema>,
1065    openapi_spec: Value,
1066    current_schema_name: Option<String>,
1067    component_parameters: BTreeMap<String, crate::openapi::Parameter>,
1068    /// Single chokepoint for `(openapi_type, format)` → Rust-type
1069    /// decisions (Q2.0). Defaulted when the analyzer is built without a
1070    /// config; threaded from `GeneratorConfig.types` via
1071    /// [`Self::with_type_mapper`].
1072    type_mapper: TypeMapper,
1073}
1074
1075impl SchemaAnalyzer {
1076    fn uses_aws_query_conventions(&self) -> bool {
1077        self.openapi_spec
1078            .pointer("/info/x-providerName")
1079            .and_then(Value::as_str)
1080            .is_some_and(|provider| provider.eq_ignore_ascii_case("amazonaws.com"))
1081    }
1082
1083    /// Construct an analyzer with a default [`TypeMapper`]. Pre-Q2.0
1084    /// callers (tests, simple bins) use this and get bit-identical
1085    /// behavior to the pre-refactor code.
1086    pub fn new(openapi_spec: Value) -> Result<Self> {
1087        Self::with_type_mapper(openapi_spec, TypeMapper::default())
1088    }
1089
1090    /// Construct an analyzer with a caller-supplied [`TypeMapper`]
1091    /// (built from `GeneratorConfig.types`). The CLI / library entry
1092    /// points use this so user TOML config drives type generation.
1093    pub fn with_type_mapper(openapi_spec: Value, type_mapper: TypeMapper) -> Result<Self> {
1094        let spec: OpenApiSpec =
1095            serde_json::from_value(openapi_spec.clone()).map_err(GeneratorError::ParseError)?;
1096        let schemas = Self::extract_schemas(&spec)?;
1097
1098        let component_parameters = spec
1099            .components
1100            .as_ref()
1101            .and_then(|c| c.parameters.as_ref())
1102            .cloned()
1103            .unwrap_or_default();
1104        Ok(Self {
1105            schemas,
1106            resolved_cache: BTreeMap::new(),
1107            openapi_spec,
1108            current_schema_name: None,
1109            component_parameters,
1110            type_mapper,
1111        })
1112    }
1113
1114    /// Create a new analyzer with schema extensions merged in (default
1115    /// type mapper).
1116    pub fn new_with_extensions(
1117        openapi_spec: Value,
1118        extension_paths: &[std::path::PathBuf],
1119    ) -> Result<Self> {
1120        let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
1121        Self::new(merged_spec)
1122    }
1123
1124    /// Same as [`Self::new_with_extensions`] but with a caller-supplied
1125    /// type mapper.
1126    pub fn new_with_extensions_and_type_mapper(
1127        openapi_spec: Value,
1128        extension_paths: &[std::path::PathBuf],
1129        type_mapper: TypeMapper,
1130    ) -> Result<Self> {
1131        let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
1132        Self::with_type_mapper(merged_spec, type_mapper)
1133    }
1134
1135    /// Borrow the analyzer's type mapper. Useful for downstream
1136    /// inspection (e.g. the dep advisory in Q2.8 reads
1137    /// `type_mapper().used_features()` after generation).
1138    pub fn type_mapper(&self) -> &TypeMapper {
1139        &self.type_mapper
1140    }
1141
1142    /// Generate a context-aware name for inline types, arrays, and variants
1143    /// This provides better naming than generic names like UnionArray1, InlineVariant2, etc.
1144    fn generate_context_aware_name(
1145        &self,
1146        base_context: &str,
1147        type_hint: &str,
1148        index: usize,
1149        schema: Option<&Schema>,
1150    ) -> String {
1151        // First, try to infer a better name from the schema structure
1152        if let Some(schema) = schema {
1153            // For arrays, check if we can derive name from items
1154            if type_hint == "Array"
1155                && matches!(schema.schema_type(), Some(OpenApiSchemaType::Array))
1156            {
1157                if let Some(items_schema) = &schema.details().items {
1158                    // Check for specific item types
1159                    if let Some(item_type) = items_schema.schema_type() {
1160                        match item_type {
1161                            OpenApiSchemaType::Object => {
1162                                return format!("{base_context}ItemArray");
1163                            }
1164                            OpenApiSchemaType::String => {
1165                                return format!("{base_context}StringArray");
1166                            }
1167                            _ => {}
1168                        }
1169                    }
1170                }
1171            }
1172        }
1173
1174        // Generate context-aware name based on type hint
1175        match type_hint {
1176            "Array" => {
1177                // For arrays, always use context name instead of generic numbering
1178                format!("{base_context}Array")
1179            }
1180            "Variant" | "InlineVariant" => {
1181                // For variants, include index only if > 0 to keep first variant clean
1182                if index == 0 {
1183                    format!("{base_context}{type_hint}")
1184                } else {
1185                    format!("{}{}{}", base_context, type_hint, index + 1)
1186                }
1187            }
1188            _ => {
1189                // Default case
1190                format!("{base_context}{type_hint}{index}")
1191            }
1192        }
1193    }
1194
1195    /// Convert a string to PascalCase, handling underscores and hyphens
1196    fn to_pascal_case(&self, s: &str) -> String {
1197        s.split(['_', '-'])
1198            .filter(|part| !part.is_empty())
1199            .map(|part| {
1200                let mut chars = part.chars();
1201                match chars.next() {
1202                    None => String::new(),
1203                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1204                }
1205            })
1206            .collect()
1207    }
1208
1209    fn extract_schemas(spec: &OpenApiSpec) -> Result<BTreeMap<String, Schema>> {
1210        // OAS 3.1+ requires only one of `paths`, `webhooks`, or `components`.
1211        // A document may legitimately have no `components.schemas` (e.g. a
1212        // webhooks-only or paths-only spec). Return an empty map in that case
1213        // and let downstream codegen handle "no types to emit" gracefully.
1214        let schemas = spec.components.as_ref().and_then(|c| c.schemas.as_ref());
1215        Ok(schemas
1216            .map(|m| {
1217                m.iter()
1218                    .map(|(k, v)| (k.clone(), v.clone()))
1219                    .collect::<BTreeMap<_, _>>()
1220            })
1221            .unwrap_or_default())
1222    }
1223
1224    pub fn analyze(&mut self) -> Result<SchemaAnalysis> {
1225        let validation_context = ValidationContext {
1226            openapi_version: self
1227                .openapi_spec
1228                .get("openapi")
1229                .and_then(Value::as_str)
1230                .unwrap_or_default()
1231                .to_string(),
1232            json_schema_dialect: self
1233                .openapi_spec
1234                .get("jsonSchemaDialect")
1235                .and_then(Value::as_str)
1236                .map(str::to_string),
1237            component_schemas: self
1238                .openapi_spec
1239                .pointer("/components/schemas")
1240                .and_then(Value::as_object)
1241                .map(|schemas| {
1242                    schemas
1243                        .iter()
1244                        .map(|(name, schema)| (name.clone(), schema.clone()))
1245                        .collect()
1246                })
1247                .unwrap_or_default(),
1248        };
1249        let mut analysis = SchemaAnalysis {
1250            schemas: BTreeMap::new(),
1251            dependencies: DependencyGraph::new(),
1252            patterns: DetectedPatterns {
1253                tagged_enum_schemas: HashSet::new(),
1254                untagged_enum_schemas: HashSet::new(),
1255                type_mappings: BTreeMap::new(),
1256            },
1257            operations: BTreeMap::new(),
1258            operation_responses: BTreeMap::new(),
1259            operation_id_aliases: BTreeMap::new(),
1260            used_type_features: crate::type_mapping::UsedFeatures::default(),
1261            enum_extensions: BTreeMap::new(),
1262            validation_context,
1263        };
1264
1265        // First pass: detect patterns
1266        self.detect_patterns(&mut analysis.patterns)?;
1267
1268        // Second pass: analyze each schema
1269        let schema_names: Vec<String> = self.schemas.keys().cloned().collect();
1270        for schema_name in schema_names {
1271            let analyzed = self.analyze_schema(&schema_name)?;
1272
1273            // Build dependency graph
1274            for dep in &analyzed.dependencies {
1275                analysis
1276                    .dependencies
1277                    .add_dependency(schema_name.clone(), dep.clone());
1278            }
1279
1280            analysis.schemas.insert(schema_name, analyzed);
1281        }
1282
1283        // Third pass: include any inline schemas that were generated during analysis
1284        // BTreeMap maintains sorted order, so iteration is deterministic
1285        for (inline_name, inline_schema) in &self.resolved_cache {
1286            if !analysis.schemas.contains_key(inline_name) {
1287                // Add the inline schema first
1288                analysis
1289                    .schemas
1290                    .insert(inline_name.clone(), inline_schema.clone());
1291
1292                // Build dependency graph for inline schema's own dependencies
1293                for dep in &inline_schema.dependencies {
1294                    analysis
1295                        .dependencies
1296                        .add_dependency(inline_name.clone(), dep.clone());
1297                }
1298
1299                // Check if any existing schemas depend on this inline schema
1300                // We need to check ALL schemas, not just the ones already in analysis.schemas,
1301                // because parent schemas might have been analyzed but their dependencies
1302                // on inline schemas might not have been added to the dependency graph yet
1303                let mut schemas_to_update = Vec::new();
1304                for (schema_name, schema) in &analysis.schemas {
1305                    // Skip self-reference
1306                    if schema_name == inline_name {
1307                        continue;
1308                    }
1309
1310                    if schema.dependencies.contains(inline_name) {
1311                        // The parent schema depends on this inline schema
1312                        schemas_to_update.push(schema_name.clone());
1313                    }
1314                }
1315
1316                // Add the dependencies to the graph
1317                for schema_name in schemas_to_update {
1318                    analysis
1319                        .dependencies
1320                        .add_dependency(schema_name, inline_name.clone());
1321                }
1322            }
1323        }
1324
1325        // Fourth pass: analyze OpenAPI operations
1326        self.analyze_operations(&mut analysis)?;
1327
1328        // Fifth pass: include any inline schemas generated during operation analysis
1329        // (e.g., inline response types)
1330        for (inline_name, inline_schema) in &self.resolved_cache {
1331            if !analysis.schemas.contains_key(inline_name) {
1332                analysis
1333                    .schemas
1334                    .insert(inline_name.clone(), inline_schema.clone());
1335
1336                // Build dependency graph for inline schema's dependencies
1337                for dep in &inline_schema.dependencies {
1338                    analysis
1339                        .dependencies
1340                        .add_dependency(inline_name.clone(), dep.clone());
1341                }
1342            }
1343        }
1344
1345        // Snapshot the type-mapper's used-features set so the
1346        // generator can decide which helper modules to emit
1347        // (e.g. base64_serde for `format: byte`).
1348        analysis.used_type_features = self.type_mapper.used_features();
1349
1350        // Q2.6: capture x-enum-varnames / x-enum-descriptions from
1351        // each enum schema's original JSON. Side-channel keyed by
1352        // analyzed-schema name so we don't have to extend every
1353        // SchemaType::StringEnum constructor.
1354        for (name, analyzed) in &analysis.schemas {
1355            let enum_value_count = match &analyzed.schema_type {
1356                SchemaType::StringEnum { values } => values.len(),
1357                SchemaType::ExtensibleEnum { known_values } => known_values.len(),
1358                _ => continue,
1359            };
1360            if let Some(ext) = extract_enum_extensions(&analyzed.original, enum_value_count, name) {
1361                analysis.enum_extensions.insert(name.clone(), ext);
1362            }
1363        }
1364
1365        Ok(analysis)
1366    }
1367
1368    fn detect_patterns(&self, patterns: &mut DetectedPatterns) -> Result<()> {
1369        for (schema_name, schema) in &self.schemas {
1370            // Detect discriminated unions
1371            if self.is_discriminated_union(schema) {
1372                patterns.tagged_enum_schemas.insert(schema_name.clone());
1373
1374                // Extract type mappings for this union
1375                if let Some(mappings) = self.extract_type_mappings(schema)? {
1376                    patterns.type_mappings.insert(schema_name.clone(), mappings);
1377                }
1378            }
1379            // Detect simple unions
1380            else if self.is_simple_union(schema) {
1381                patterns.untagged_enum_schemas.insert(schema_name.clone());
1382            }
1383        }
1384
1385        Ok(())
1386    }
1387
1388    fn is_discriminated_union(&self, schema: &Schema) -> bool {
1389        // Check for explicit discriminator
1390        if schema.is_discriminated_union() {
1391            return true;
1392        }
1393
1394        // Auto-detect from union patterns with any common const field
1395        if let Some(variants) = schema.union_variants() {
1396            return variants.len() > 2 && self.detect_discriminator_field(variants).is_some();
1397        }
1398
1399        false
1400    }
1401
1402    fn all_variants_have_const_field(&self, variants: &[Schema], field_name: &str) -> bool {
1403        variants.iter().all(|variant| {
1404            if let Some(ref_str) = variant.reference() {
1405                // $ref variant: resolve and check the referenced schema
1406                if let Some(schema_name) = self.extract_schema_name(ref_str) {
1407                    if let Some(schema) = self.schemas.get(schema_name) {
1408                        return self.has_const_discriminator_field(schema, field_name);
1409                    }
1410                }
1411            } else {
1412                // Inline variant: check properties directly
1413                return self.has_const_discriminator_field(variant, field_name);
1414            }
1415            false
1416        })
1417    }
1418
1419    /// True when this branch of an anyOf/oneOf is (or resolves to) an
1420    /// object — the only kind of schema serde can deserialize via an
1421    /// internally-tagged enum. False for string/number/bool/array branches
1422    /// or refs to those, including string-enums.
1423    ///
1424    /// Used to detect the "hybrid string-or-object" union pattern (see bug
1425    /// openapi-generator-dpd) so we can downgrade those unions to
1426    /// `#[serde(untagged)]`.
1427    fn branch_resolves_to_object(&self, schema: &Schema) -> bool {
1428        // Follow $ref one hop, then ask the same question of the target.
1429        if let Some(ref_str) = schema.reference() {
1430            return match self
1431                .extract_schema_name(ref_str)
1432                .and_then(|n| self.schemas.get(n))
1433            {
1434                Some(target) => self.branch_resolves_to_object(target),
1435                None => false,
1436            };
1437        }
1438        // allOf compositions are object-shaped; same for anyOf/oneOf
1439        // wrappers (those will reduce to objects or to further unions).
1440        if matches!(
1441            schema,
1442            Schema::AllOf { .. } | Schema::AnyOf { .. } | Schema::OneOf { .. }
1443        ) {
1444            return true;
1445        }
1446        if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object)) {
1447            return true;
1448        }
1449        if schema.inferred_type() == Some(OpenApiSchemaType::Object) {
1450            return true;
1451        }
1452        // Anything else (string, integer, number, boolean, array, null,
1453        // string-enum, etc.) cannot carry a JSON tag field.
1454        false
1455    }
1456
1457    /// Scan all variants to find any common property that has a const/single-enum value
1458    /// across all variants. Returns the field name if found.
1459    /// Prioritizes "type" if it matches (most common convention).
1460    fn detect_discriminator_field(&self, variants: &[Schema]) -> Option<String> {
1461        if variants.is_empty() {
1462            return None;
1463        }
1464
1465        // Collect candidate field names from the first variant
1466        let first_variant = &variants[0];
1467        let first_schema = if let Some(ref_str) = first_variant.reference() {
1468            let schema_name = self.extract_schema_name(ref_str)?;
1469            self.schemas.get(schema_name)?
1470        } else {
1471            first_variant
1472        };
1473
1474        let properties = first_schema.details().properties.as_ref()?;
1475        let mut candidates: Vec<String> = Vec::new();
1476
1477        for (field_name, field_schema) in properties {
1478            let details = field_schema.details();
1479            let is_const = details.const_value.is_some()
1480                || details.enum_values.as_ref().is_some_and(|v| v.len() == 1)
1481                || details.extra.contains_key("const");
1482            if is_const {
1483                candidates.push(field_name.clone());
1484            }
1485        }
1486
1487        if candidates.is_empty() {
1488            return None;
1489        }
1490
1491        // Prioritize "type" if it's among candidates
1492        candidates.sort_by(|a, b| {
1493            if a == "type" {
1494                std::cmp::Ordering::Less
1495            } else if b == "type" {
1496                std::cmp::Ordering::Greater
1497            } else {
1498                a.cmp(b)
1499            }
1500        });
1501
1502        // Check each candidate against all variants
1503        for candidate in &candidates {
1504            if self.all_variants_have_const_field(variants, candidate) {
1505                return Some(candidate.clone());
1506            }
1507        }
1508
1509        None
1510    }
1511
1512    fn has_const_discriminator_field(&self, schema: &Schema, field_name: &str) -> bool {
1513        if let Some(properties) = &schema.details().properties {
1514            if let Some(field) = properties.get(field_name) {
1515                // Check for const value (OpenAPI 3.1 style)
1516                if field.details().const_value.is_some() {
1517                    return true;
1518                }
1519                // Check if it's an enum field with a single value
1520                if let Some(enum_vals) = &field.details().enum_values {
1521                    return enum_vals.len() == 1;
1522                }
1523                // Fallback: check extra fields for const
1524                return field.details().extra.contains_key("const");
1525            }
1526        }
1527        false
1528    }
1529
1530    fn is_simple_union(&self, schema: &Schema) -> bool {
1531        if let Some(variants) = schema.union_variants() {
1532            // Simple union: multiple types but not nullable pattern
1533            if variants.len() > 1 && !schema.is_nullable_pattern() {
1534                let has_refs = variants.iter().any(|v| v.is_reference());
1535                return has_refs;
1536            }
1537        }
1538        false
1539    }
1540
1541    fn extract_type_mappings(&self, schema: &Schema) -> Result<Option<BTreeMap<String, String>>> {
1542        let variants = schema.union_variants().ok_or_else(|| {
1543            GeneratorError::InvalidSchema("No variants found for discriminated union".to_string())
1544        })?;
1545
1546        // Get the discriminator field name from the schema
1547        let discriminator_field = if let Some(discriminator) = schema.discriminator() {
1548            discriminator.property_name.clone()
1549        } else if let Some(detected) = self.detect_discriminator_field(variants) {
1550            detected
1551        } else {
1552            "type".to_string() // fallback to "type" for auto-detected discriminated unions
1553        };
1554
1555        let mut mappings = BTreeMap::new();
1556
1557        for variant in variants {
1558            if let Some(ref_str) = variant.reference() {
1559                if let Some(type_name) = self.extract_schema_name(ref_str) {
1560                    if let Some(variant_schema) = self.schemas.get(type_name) {
1561                        if let Some(discriminator_value) = self
1562                            .extract_discriminator_value_for_field(
1563                                variant_schema,
1564                                &discriminator_field,
1565                            )
1566                        {
1567                            mappings.insert(type_name.to_string(), discriminator_value);
1568                        }
1569                    }
1570                }
1571            }
1572        }
1573
1574        if mappings.is_empty() {
1575            Ok(None)
1576        } else {
1577            Ok(Some(mappings))
1578        }
1579    }
1580
1581    #[allow(dead_code)]
1582    fn extract_discriminator_value(&self, schema: &Schema) -> Option<String> {
1583        self.extract_discriminator_value_for_field(schema, "type")
1584    }
1585
1586    fn extract_discriminator_value_for_field(
1587        &self,
1588        schema: &Schema,
1589        field_name: &str,
1590    ) -> Option<String> {
1591        if let Some(properties) = &schema.details().properties {
1592            if let Some(type_field) = properties.get(field_name) {
1593                // Check for const value first (highest priority)
1594                if let Some(const_value) = &type_field.details().const_value {
1595                    if let Some(value) = const_value.as_str() {
1596                        return Some(value.to_string());
1597                    }
1598                }
1599                // Check for enum with single value
1600                if let Some(enum_values) = &type_field.details().enum_values {
1601                    if enum_values.len() == 1 {
1602                        return enum_values[0].as_str().map(|s| s.to_string());
1603                    }
1604                }
1605                // Check for const value in extra fields
1606                if let Some(const_value) = type_field.details().extra.get("const") {
1607                    return const_value.as_str().map(|s| s.to_string());
1608                }
1609                // Check for x-stainless-const with default value
1610                if let Some(stainless_const) = type_field.details().extra.get("x-stainless-const") {
1611                    if stainless_const.as_bool() == Some(true) {
1612                        if let Some(default_value) = &type_field.details().default {
1613                            if let Some(value) = default_value.as_str() {
1614                                return Some(value.to_string());
1615                            }
1616                        }
1617                    }
1618                }
1619            }
1620        }
1621        None
1622    }
1623
1624    fn get_any_reference<'a>(&self, schema: &'a Schema) -> Option<&'a str> {
1625        schema.reference().or_else(|| schema.recursive_reference())
1626    }
1627
1628    fn extract_schema_name<'a>(&self, ref_str: &'a str) -> Option<&'a str> {
1629        if ref_str == "#" {
1630            return None; // Special case for self-reference
1631        }
1632
1633        let parts: Vec<&str> = ref_str.split('/').collect();
1634
1635        // Standard 3.x pattern: #/components/schemas/{SchemaName}[/deeper/path]
1636        if parts.len() >= 4 && parts[0] == "#" && parts[2] == "schemas" {
1637            return Some(parts[3]);
1638        }
1639
1640        // Swagger 2.0 carry-over: some 3.x specs (Google) still use
1641        // `#/definitions/{SchemaName}`. Treat it as an alias.
1642        if parts.len() >= 3 && parts[0] == "#" && parts[1] == "definitions" {
1643            return Some(parts[2]);
1644        }
1645
1646        // Last-segment fallback for other ref shapes — but only if the
1647        // segment plausibly names a top-level schema (PascalCase, no digits-
1648        // only, not a JSON-schema keyword like `schema`/`properties`/`items`).
1649        // pagerduty has `#/components/parameters/foo/schema`, where the last
1650        // segment "schema" is a sub-path indicator, not a schema name.
1651        let last = parts.last()?;
1652        if last.is_empty()
1653            || last.chars().all(|c| c.is_ascii_digit())
1654            || matches!(
1655                *last,
1656                "schema" | "properties" | "items" | "additionalProperties"
1657            )
1658        {
1659            return None;
1660        }
1661        let first = last.chars().next().unwrap_or(' ');
1662        if !first.is_ascii_alphabetic() || !first.is_ascii_uppercase() {
1663            return None;
1664        }
1665        Some(last)
1666    }
1667
1668    fn analyze_schema(&mut self, schema_name: &str) -> Result<AnalyzedSchema> {
1669        // Check cache first
1670        if let Some(cached) = self.resolved_cache.get(schema_name) {
1671            return Ok(cached.clone());
1672        }
1673
1674        // Set current schema name for context
1675        self.current_schema_name = Some(schema_name.to_string());
1676
1677        let schema = self
1678            .schemas
1679            .get(schema_name)
1680            .ok_or_else(|| GeneratorError::UnresolvedReference(schema_name.to_string()))?
1681            .clone();
1682
1683        // Prevent infinite recursion with placeholder
1684        self.resolved_cache.insert(
1685            schema_name.to_string(),
1686            AnalyzedSchema {
1687                name: schema_name.to_string(),
1688                original: serde_json::to_value(&schema).unwrap_or(Value::Null),
1689                schema_type: SchemaType::Reference {
1690                    target: "placeholder".to_string(),
1691                },
1692                dependencies: HashSet::new(),
1693                nullable: false,
1694                description: None,
1695                default: None,
1696            },
1697        );
1698
1699        let analyzed = self.analyze_schema_value(&schema, schema_name)?;
1700
1701        // Update cache with real result
1702        self.resolved_cache
1703            .insert(schema_name.to_string(), analyzed.clone());
1704
1705        Ok(analyzed)
1706    }
1707
1708    fn analyze_schema_value(
1709        &mut self,
1710        schema: &Schema,
1711        schema_name: &str,
1712    ) -> Result<AnalyzedSchema> {
1713        let details = schema.details();
1714        let description = details.description.clone();
1715        // Combine 3.0-style `nullable: true` with 3.1's `type: ["X", "null"]`.
1716        let nullable = details.is_nullable() || schema.type_array_contains_null();
1717        let mut dependencies = HashSet::new();
1718
1719        let schema_type = match schema {
1720            Schema::Reference { reference, .. } => {
1721                // For real-world refs we can't resolve to a known schema name
1722                // (e.g. pagerduty's `#/components/parameters/foo/schema`),
1723                // fall back to opaque JSON instead of failing whole-document
1724                // generation. The rest of the spec is usually unaffected.
1725                match self.extract_schema_name(reference) {
1726                    Some(name) => {
1727                        let target = name.to_string();
1728                        dependencies.insert(target.clone());
1729                        SchemaType::Reference { target }
1730                    }
1731                    None => {
1732                        eprintln!(
1733                            "⚠️  unresolvable $ref `{}` — typing as serde_json::Value",
1734                            reference
1735                        );
1736                        SchemaType::Primitive {
1737                            rust_type: "serde_json::Value".to_string(),
1738                            serde_with: None,
1739                        }
1740                    }
1741                }
1742            }
1743            Schema::RecursiveRef { recursive_ref, .. }
1744            | Schema::DynamicRef {
1745                dynamic_ref: recursive_ref,
1746                ..
1747            } => {
1748                // Handle recursive / dynamic references. J1: full $dynamicRef
1749                // resolution against $dynamicAnchor scopes is a follow-up; for
1750                // now we treat them like recursive refs (self-reference when
1751                // it's a fragment to the same schema, otherwise resolve via
1752                // schema name).
1753                if recursive_ref == "#" {
1754                    dependencies.insert(schema_name.to_string());
1755                    SchemaType::Reference {
1756                        target: schema_name.to_string(),
1757                    }
1758                } else {
1759                    let target = self
1760                        .extract_schema_name(recursive_ref)
1761                        .unwrap_or(schema_name)
1762                        .to_string();
1763                    dependencies.insert(target.clone());
1764                    SchemaType::Reference { target }
1765                }
1766            }
1767            Schema::Typed { .. } | Schema::TypedMulti { .. } => {
1768                let primary = schema
1769                    .schema_type()
1770                    .cloned()
1771                    .unwrap_or(OpenApiSchemaType::Object);
1772                let format = details.format.as_deref();
1773                match primary {
1774                    OpenApiSchemaType::String => {
1775                        if let Some(values) = details.string_enum_values() {
1776                            SchemaType::StringEnum { values }
1777                        } else {
1778                            SchemaType::Primitive {
1779                                rust_type: self.type_mapper.string_format(format).rust_type,
1780                                serde_with: None,
1781                            }
1782                        }
1783                    }
1784                    OpenApiSchemaType::Integer => SchemaType::Primitive {
1785                        rust_type: self.type_mapper.integer_format(format).rust_type,
1786                        serde_with: None,
1787                    },
1788                    OpenApiSchemaType::Number => SchemaType::Primitive {
1789                        rust_type: self.type_mapper.number_format(format).rust_type,
1790                        serde_with: None,
1791                    },
1792                    OpenApiSchemaType::Boolean => SchemaType::Primitive {
1793                        rust_type: self.type_mapper.boolean().rust_type,
1794                        serde_with: None,
1795                    },
1796                    OpenApiSchemaType::Array => {
1797                        // Analyze array item type
1798                        self.analyze_array_schema(schema, schema_name, &mut dependencies)?
1799                    }
1800                    OpenApiSchemaType::Object => {
1801                        // Check if this is a dynamic JSON object
1802                        if self.should_use_dynamic_json(schema) {
1803                            SchemaType::Primitive {
1804                                rust_type: self.type_mapper.dynamic_json().rust_type,
1805                                serde_with: None,
1806                            }
1807                        } else {
1808                            // Analyze object properties
1809                            self.analyze_object_schema(schema, &mut dependencies)?
1810                        }
1811                    }
1812                    _ => SchemaType::Primitive {
1813                        rust_type: self.type_mapper.dynamic_json().rust_type,
1814                        serde_with: None,
1815                    },
1816                }
1817            }
1818            Schema::AnyOf {
1819                any_of,
1820                discriminator,
1821                ..
1822            } => {
1823                // Handle anyOf patterns (nullable vs flexible union vs discriminated)
1824                self.analyze_anyof_union(
1825                    any_of,
1826                    discriminator.as_ref(),
1827                    &mut dependencies,
1828                    schema_name,
1829                )?
1830            }
1831            Schema::OneOf {
1832                one_of,
1833                discriminator,
1834                ..
1835            } => {
1836                // Handle oneOf discriminated unions
1837                self.analyze_oneof_union(
1838                    one_of,
1839                    discriminator.as_ref(),
1840                    schema_name,
1841                    &mut dependencies,
1842                )?
1843            }
1844            Schema::AllOf { all_of, .. } => {
1845                // Handle allOf composition (schema inheritance)
1846                self.analyze_allof_composition(all_of, &mut dependencies)?
1847            }
1848            Schema::Untyped { .. } => {
1849                // Try to infer type from structure
1850                if let Some(inferred) = schema.inferred_type() {
1851                    match inferred {
1852                        OpenApiSchemaType::Object => {
1853                            if self.should_use_dynamic_json(schema) {
1854                                SchemaType::Primitive {
1855                                    rust_type: "serde_json::Value".to_string(),
1856                                    serde_with: None,
1857                                }
1858                            } else {
1859                                self.analyze_object_schema(schema, &mut dependencies)?
1860                            }
1861                        }
1862                        OpenApiSchemaType::String if details.is_string_enum() => {
1863                            SchemaType::StringEnum {
1864                                values: details.string_enum_values().unwrap_or_default(),
1865                            }
1866                        }
1867                        _ => SchemaType::Primitive {
1868                            rust_type: "serde_json::Value".to_string(),
1869                            serde_with: None,
1870                        },
1871                    }
1872                } else {
1873                    SchemaType::Primitive {
1874                        rust_type: "serde_json::Value".to_string(),
1875                        serde_with: None,
1876                    }
1877                }
1878            }
1879        };
1880
1881        Ok(AnalyzedSchema {
1882            name: schema_name.to_string(),
1883            original: serde_json::to_value(schema).unwrap_or(Value::Null), // Convert back to Value for now
1884            schema_type,
1885            dependencies,
1886            nullable,
1887            description,
1888            default: details.default.clone(),
1889        })
1890    }
1891
1892    fn analyze_object_schema(
1893        &mut self,
1894        schema: &Schema,
1895        dependencies: &mut HashSet<String>,
1896    ) -> Result<SchemaType> {
1897        let details = schema.details();
1898        let properties = &details.properties;
1899        let required = details
1900            .required
1901            .as_ref()
1902            .map(|req| req.iter().cloned().collect::<HashSet<String>>())
1903            .unwrap_or_default();
1904
1905        let mut property_info = BTreeMap::new();
1906
1907        if let Some(props) = properties {
1908            for (prop_name, prop_schema) in props {
1909                // Check if this property is a union that needs a named type
1910                let prop_type = if let Schema::AnyOf { any_of, .. } = prop_schema {
1911                    // First check if this should be a dynamic JSON pattern
1912                    if self.should_use_dynamic_json(prop_schema) {
1913                        // This is a dynamic JSON pattern, use serde_json::Value directly
1914                        SchemaType::Primitive {
1915                            rust_type: "serde_json::Value".to_string(),
1916                            serde_with: None,
1917                        }
1918                    } else if prop_schema.is_nullable_pattern()
1919                        && let Some(non_null) = prop_schema.non_null_variant()
1920                    {
1921                        // 3.1 idiom: `anyOf: [<schema>, {type: null}]`. The
1922                        // wrapper has no semantic value beyond nullability;
1923                        // unwrap to the inner type. Without this, the synthesized
1924                        // wrapper type collides with the inner $ref's name when
1925                        // the property name produces a colliding parent context
1926                        // (e.g. `Step.status` → `StepStatus`, which is also the
1927                        // referenced component).
1928                        self.analyze_property_schema_with_context(
1929                            non_null,
1930                            Some(prop_name),
1931                            dependencies,
1932                        )?
1933                    } else {
1934                        // This is an anyOf union in a property - create a named union type
1935                        // Use the current schema name as context to make the union name unique
1936                        let context_name = self
1937                            .current_schema_name
1938                            .clone()
1939                            .unwrap_or_else(|| "Unknown".to_string());
1940
1941                        // Generate a name based on both the schema and property name
1942                        let prop_pascal = self.to_pascal_case(prop_name);
1943                        let mut union_type_name = format!("{context_name}{prop_pascal}");
1944
1945                        // Avoid colliding with an existing component schema or
1946                        // an inline name that's already in resolved_cache.
1947                        if self.schemas.contains_key(&union_type_name)
1948                            || self.resolved_cache.contains_key(&union_type_name)
1949                        {
1950                            let mut suffix = 2;
1951                            loop {
1952                                let candidate = format!("{union_type_name}Union{suffix}");
1953                                if !self.schemas.contains_key(&candidate)
1954                                    && !self.resolved_cache.contains_key(&candidate)
1955                                {
1956                                    union_type_name = candidate;
1957                                    break;
1958                                }
1959                                suffix += 1;
1960                                if suffix > 1000 {
1961                                    break;
1962                                }
1963                            }
1964                        }
1965
1966                        // Analyze the union
1967                        let union_schema_type = self.analyze_anyof_union(
1968                            any_of,
1969                            prop_schema.discriminator(),
1970                            dependencies,
1971                            &union_type_name,
1972                        )?;
1973
1974                        // Store the union as a named schema
1975                        self.resolved_cache.insert(
1976                            union_type_name.clone(),
1977                            AnalyzedSchema {
1978                                name: union_type_name.clone(),
1979                                original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
1980                                schema_type: union_schema_type,
1981                                dependencies: HashSet::new(),
1982                                nullable: false,
1983                                description: prop_schema.details().description.clone(),
1984                                default: None,
1985                            },
1986                        );
1987
1988                        // Return a reference to the named union type
1989                        dependencies.insert(union_type_name.clone());
1990                        SchemaType::Reference {
1991                            target: union_type_name,
1992                        }
1993                    }
1994                } else if let Schema::OneOf {
1995                    one_of,
1996                    discriminator,
1997                    ..
1998                } = prop_schema
1999                {
2000                    // 3.1 idiom: `oneOf: [<schema>, {type: null}]`. Same
2001                    // unwrap as anyOf above — without this, the synthesized
2002                    // wrapper type collides with the inner $ref's name
2003                    // (discord's `QuarantineUserAction.metadata` →
2004                    // `QuarantineUserActionMetadata` clashing with the
2005                    // referenced `QuarantineUserActionMetadata` schema).
2006                    if prop_schema.is_nullable_pattern()
2007                        && let Some(non_null) = prop_schema.non_null_variant()
2008                    {
2009                        let unwrapped = self.analyze_property_schema_with_context(
2010                            non_null,
2011                            Some(prop_name),
2012                            dependencies,
2013                        )?;
2014                        let prop_details = prop_schema.details();
2015                        let prop_nullable = true;
2016                        let prop_description = prop_details.description.clone();
2017                        let prop_default = prop_details.default.clone();
2018                        property_info.insert(
2019                            prop_name.clone(),
2020                            PropertyInfo {
2021                                schema_type: unwrapped,
2022                                nullable: prop_nullable,
2023                                description: prop_description,
2024                                default: prop_default,
2025                                serde_attrs: Vec::new(),
2026                                constraints: PropertyConstraints::from_schema_details(prop_details),
2027                            },
2028                        );
2029                        continue;
2030                    }
2031
2032                    // Handle oneOf discriminated unions in properties
2033                    let context_name = self
2034                        .current_schema_name
2035                        .clone()
2036                        .unwrap_or_else(|| "Unknown".to_string());
2037                    let prop_pascal = self.to_pascal_case(prop_name);
2038                    let mut union_type_name = format!("{context_name}{prop_pascal}");
2039                    // Same collision-suffix dance as the anyOf branch above.
2040                    if self.schemas.contains_key(&union_type_name)
2041                        || self.resolved_cache.contains_key(&union_type_name)
2042                    {
2043                        let mut suffix = 2;
2044                        loop {
2045                            let candidate = format!("{union_type_name}Union{suffix}");
2046                            if !self.schemas.contains_key(&candidate)
2047                                && !self.resolved_cache.contains_key(&candidate)
2048                            {
2049                                union_type_name = candidate;
2050                                break;
2051                            }
2052                            suffix += 1;
2053                            if suffix > 1000 {
2054                                break;
2055                            }
2056                        }
2057                    }
2058
2059                    // Analyze the discriminated union
2060                    let union_schema_type = self.analyze_oneof_union(
2061                        one_of,
2062                        discriminator.as_ref(),
2063                        &union_type_name,
2064                        dependencies,
2065                    )?;
2066
2067                    // Store the union as a named schema
2068                    self.resolved_cache.insert(
2069                        union_type_name.clone(),
2070                        AnalyzedSchema {
2071                            name: union_type_name.clone(),
2072                            original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
2073                            schema_type: union_schema_type,
2074                            dependencies: HashSet::new(),
2075                            nullable: false,
2076                            description: prop_schema.details().description.clone(),
2077                            default: None,
2078                        },
2079                    );
2080
2081                    // Return a reference to the named union type
2082                    dependencies.insert(union_type_name.clone());
2083                    SchemaType::Reference {
2084                        target: union_type_name,
2085                    }
2086                } else {
2087                    // Regular property schema analysis - pass property name for context
2088                    self.analyze_property_schema_with_context(
2089                        prop_schema,
2090                        Some(prop_name),
2091                        dependencies,
2092                    )?
2093                };
2094
2095                let prop_details = prop_schema.details();
2096                // Every nullability form, via one helper — see is_nullable_any.
2097                let prop_nullable = prop_schema.is_nullable_any();
2098                let prop_description = prop_details.description.clone();
2099                let prop_default = prop_details.default.clone();
2100
2101                property_info.insert(
2102                    prop_name.clone(),
2103                    PropertyInfo {
2104                        schema_type: prop_type,
2105                        nullable: prop_nullable,
2106                        description: prop_description,
2107                        default: prop_default,
2108                        serde_attrs: Vec::new(),
2109                        constraints: PropertyConstraints::from_schema_details(prop_details),
2110                    },
2111                );
2112            }
2113        }
2114
2115        // Q2.3: classify additionalProperties three ways. When the
2116        // spec gives us a schema we analyze it and emit a typed
2117        // BTreeMap<String, T>; pre-Q2.3 collapsed both Schema and
2118        // Boolean(true) to the same untyped map. Toggle:
2119        //   [generator.types.shape] additional_properties_typed
2120        // Default true; setting false reverts the schema case to
2121        // Untyped (current pre-Q2.3 behavior).
2122        let typed_enabled = self
2123            .type_mapper
2124            .config()
2125            .shape
2126            .as_ref()
2127            .and_then(|s| s.additional_properties_typed)
2128            .unwrap_or(true);
2129
2130        let additional_properties = match &details.additional_properties {
2131            Some(crate::openapi::AdditionalProperties::Boolean(true)) => {
2132                ObjectAdditionalProperties::Untyped
2133            }
2134            Some(crate::openapi::AdditionalProperties::Boolean(false)) => {
2135                ObjectAdditionalProperties::Forbidden
2136            }
2137            Some(crate::openapi::AdditionalProperties::Schema(value_schema)) if typed_enabled => {
2138                let analyzed =
2139                    self.analyze_property_schema_with_context(value_schema, None, dependencies)?;
2140                ObjectAdditionalProperties::Typed {
2141                    value_type: Box::new(analyzed),
2142                }
2143            }
2144            Some(crate::openapi::AdditionalProperties::Schema(_)) => {
2145                // typed_enabled = false: degrade to the pre-Q2.3 behavior.
2146                ObjectAdditionalProperties::Untyped
2147            }
2148            None => ObjectAdditionalProperties::Forbidden,
2149        };
2150
2151        Ok(SchemaType::Object {
2152            properties: property_info,
2153            required,
2154            additional_properties,
2155        })
2156    }
2157
2158    fn analyze_property_schema_with_context(
2159        &mut self,
2160        schema: &Schema,
2161        property_name: Option<&str>,
2162        dependencies: &mut HashSet<String>,
2163    ) -> Result<SchemaType> {
2164        if let Some(ref_str) = self.get_any_reference(schema) {
2165            let target_opt = if ref_str == "#" {
2166                Some(
2167                    self.find_recursive_anchor_schema()
2168                        .unwrap_or_else(|| "UnknownRecursive".to_string()),
2169                )
2170            } else {
2171                self.extract_schema_name(ref_str).map(|s| s.to_string())
2172            };
2173            match target_opt {
2174                Some(target) => {
2175                    dependencies.insert(target.clone());
2176                    return Ok(SchemaType::Reference { target });
2177                }
2178                None => {
2179                    eprintln!(
2180                        "⚠️  unresolvable $ref `{}` — typing as serde_json::Value",
2181                        ref_str
2182                    );
2183                    return Ok(SchemaType::Primitive {
2184                        rust_type: "serde_json::Value".to_string(),
2185                        serde_with: None,
2186                    });
2187                }
2188            }
2189        }
2190
2191        if let Some(schema_type) = schema.schema_type() {
2192            match schema_type {
2193                OpenApiSchemaType::String => {
2194                    // Check if this string type has enum values
2195                    if let Some(enum_values) = schema.details().string_enum_values() {
2196                        // This is an inline enum in a property - create a named enum type
2197                        // Use the current schema name as context to make the enum name unique
2198                        let context_name = self
2199                            .current_schema_name
2200                            .clone()
2201                            .unwrap_or_else(|| "Unknown".to_string());
2202
2203                        // Generate a candidate name based on both the schema and property context.
2204                        let primary_name = if let Some(prop_name) = property_name {
2205                            // We have property name context - use it for a unique name
2206                            let prop_pascal = self.to_pascal_case(prop_name);
2207                            format!("{context_name}{prop_pascal}")
2208                        } else {
2209                            // No property name context - generate a unique name using enum values
2210                            // Use the first enum value to help make the name unique
2211                            let suffix = if !enum_values.is_empty() {
2212                                let first_value = self.to_pascal_case(&enum_values[0]);
2213                                format!("{first_value}Enum")
2214                            } else {
2215                                "StringEnum".to_string()
2216                            };
2217                            format!("{context_name}{suffix}")
2218                        };
2219
2220                        return Ok(self.hoist_inline_string_enum(
2221                            schema,
2222                            enum_values,
2223                            primary_name,
2224                            dependencies,
2225                        ));
2226                    } else {
2227                        // Property-level string with no enum values:
2228                        // route through TypeMapper so `format: date-time`
2229                        // / `uuid` / etc. surface as typed scalars
2230                        // (chrono::DateTime, uuid::Uuid, …) instead of
2231                        // collapsing to bare `String`.
2232                        let mapped = self
2233                            .type_mapper
2234                            .string_format(schema.details().format.as_deref());
2235                        return Ok(SchemaType::Primitive {
2236                            rust_type: mapped.rust_type,
2237                            serde_with: mapped.serde_with,
2238                        });
2239                    }
2240                }
2241                OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
2242                    let details = schema.details();
2243                    let rust_type = self.get_number_rust_type(schema_type.clone(), details);
2244                    return Ok(SchemaType::Primitive {
2245                        rust_type,
2246                        serde_with: None,
2247                    });
2248                }
2249                OpenApiSchemaType::Boolean => {
2250                    return Ok(SchemaType::Primitive {
2251                        rust_type: "bool".to_string(),
2252                        serde_with: None,
2253                    });
2254                }
2255                OpenApiSchemaType::Array => {
2256                    // Analyze array property with context
2257                    let context_name = if let Some(prop_name) = property_name {
2258                        // Use property name for context
2259                        let prop_pascal = self.to_pascal_case(prop_name);
2260                        format!(
2261                            "{}{}",
2262                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2263                            prop_pascal
2264                        )
2265                    } else {
2266                        // Fallback to generic name
2267                        "ArrayItem".to_string()
2268                    };
2269                    return self.analyze_array_schema(schema, &context_name, dependencies);
2270                }
2271                OpenApiSchemaType::Object => {
2272                    // Check if this is a dynamic JSON object
2273                    if self.should_use_dynamic_json(schema) {
2274                        return Ok(SchemaType::Primitive {
2275                            rust_type: "serde_json::Value".to_string(),
2276                            serde_with: None,
2277                        });
2278                    }
2279                    // Inline object in property - create a named schema for it
2280                    let object_type_name = if let Some(prop_name) = property_name {
2281                        // Use property name for context
2282                        let prop_pascal = self.to_pascal_case(prop_name);
2283                        format!(
2284                            "{}{}",
2285                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2286                            prop_pascal
2287                        )
2288                    } else {
2289                        // Fallback to generic name
2290                        format!(
2291                            "{}Object",
2292                            self.current_schema_name.as_deref().unwrap_or("Unknown")
2293                        )
2294                    };
2295
2296                    // Analyze the object schema
2297                    let object_type = self.analyze_object_schema(schema, dependencies)?;
2298
2299                    // Create an analyzed schema for the inline object
2300                    let inline_schema = AnalyzedSchema {
2301                        name: object_type_name.clone(),
2302                        original: serde_json::to_value(schema).unwrap_or(Value::Null),
2303                        schema_type: object_type,
2304                        dependencies: dependencies.clone(),
2305                        nullable: false,
2306                        description: schema.details().description.clone(),
2307                        default: None,
2308                    };
2309
2310                    // Add the inline object as a named schema
2311                    self.resolved_cache
2312                        .insert(object_type_name.clone(), inline_schema);
2313                    dependencies.insert(object_type_name.clone());
2314
2315                    // Return a reference to the named schema
2316                    return Ok(SchemaType::Reference {
2317                        target: object_type_name,
2318                    });
2319                }
2320                _ => {
2321                    return Ok(SchemaType::Primitive {
2322                        rust_type: "serde_json::Value".to_string(),
2323                        serde_with: None,
2324                    });
2325                }
2326            }
2327        }
2328
2329        // Handle nullable patterns
2330        if schema.is_nullable_pattern() {
2331            if let Some(non_null) = schema.non_null_variant() {
2332                return self.analyze_property_schema_with_context(
2333                    non_null,
2334                    property_name,
2335                    dependencies,
2336                );
2337            }
2338        }
2339
2340        // Check if this should be dynamic JSON before further analysis
2341        if self.should_use_dynamic_json(schema) {
2342            return Ok(SchemaType::Primitive {
2343                rust_type: "serde_json::Value".to_string(),
2344                serde_with: None,
2345            });
2346        }
2347
2348        // Handle allOf composition patterns
2349        if let Schema::AllOf { all_of, .. } = schema {
2350            return self.analyze_allof_composition(all_of, dependencies);
2351        }
2352
2353        // Handle union patterns (anyOf/oneOf) that weren't caught earlier
2354        if let Some(variants) = schema.union_variants() {
2355            match variants.len().cmp(&1) {
2356                std::cmp::Ordering::Equal => {
2357                    // Single variant - analyze it directly
2358                    return self.analyze_property_schema_with_context(
2359                        &variants[0],
2360                        property_name,
2361                        dependencies,
2362                    );
2363                }
2364                std::cmp::Ordering::Greater => {
2365                    // Multiple variants - try to analyze as a union
2366                    // Generate a context-aware name for the union type
2367                    let union_name = if let Some(prop_name) = property_name {
2368                        // We have property context - create a proper union name
2369                        let prop_pascal = self.to_pascal_case(prop_name);
2370                        format!(
2371                            "{}{}",
2372                            self.current_schema_name.as_deref().unwrap_or(""),
2373                            prop_pascal
2374                        )
2375                    } else {
2376                        "UnionType".to_string()
2377                    };
2378
2379                    // Check if this is a oneOf or anyOf
2380                    if let Schema::OneOf {
2381                        one_of,
2382                        discriminator,
2383                        ..
2384                    } = schema
2385                    {
2386                        // This is a oneOf - analyze it properly with potential discriminator
2387                        let oneof_result = self.analyze_oneof_union(
2388                            one_of,
2389                            discriminator.as_ref(),
2390                            &union_name,
2391                            dependencies,
2392                        )?;
2393
2394                        // If we got a union type (not discriminated), we need to store it as a named type
2395                        if let SchemaType::Union {
2396                            variants: _union_variants,
2397                        } = &oneof_result
2398                        {
2399                            // Store the union as a named type in resolved_cache
2400                            self.resolved_cache.insert(
2401                                union_name.clone(),
2402                                AnalyzedSchema {
2403                                    name: union_name.clone(),
2404                                    original: serde_json::to_value(schema).unwrap_or(Value::Null),
2405                                    schema_type: oneof_result.clone(),
2406                                    dependencies: dependencies.clone(),
2407                                    nullable: false,
2408                                    description: schema.details().description.clone(),
2409                                    default: None,
2410                                },
2411                            );
2412
2413                            // Return a reference to the named union type
2414                            dependencies.insert(union_name.clone());
2415                            return Ok(SchemaType::Reference { target: union_name });
2416                        }
2417
2418                        return Ok(oneof_result);
2419                    } else if let Schema::AnyOf {
2420                        any_of,
2421                        discriminator,
2422                        ..
2423                    } = schema
2424                    {
2425                        // This is anyOf - use existing logic with discriminator support
2426                        let union_analysis = self.analyze_anyof_union(
2427                            any_of,
2428                            discriminator.as_ref(),
2429                            dependencies,
2430                            &union_name,
2431                        )?;
2432                        return Ok(union_analysis);
2433                    } else {
2434                        // This shouldn't happen, but handle gracefully
2435                        // Create a simple union from variants
2436                        let mut union_variants = Vec::new();
2437                        for variant in variants {
2438                            if let Some(ref_str) = variant.reference() {
2439                                if let Some(target) = self.extract_schema_name(ref_str) {
2440                                    dependencies.insert(target.to_string());
2441                                    union_variants.push(SchemaRef {
2442                                        target: target.to_string(),
2443                                        nullable: false,
2444                                    });
2445                                }
2446                            }
2447                        }
2448                        return Ok(SchemaType::Union {
2449                            variants: union_variants,
2450                        });
2451                    }
2452                }
2453                std::cmp::Ordering::Less => {}
2454            }
2455        }
2456
2457        // Handle untyped schemas by trying to infer from structure
2458        if let Some(inferred_type) = schema.inferred_type() {
2459            match inferred_type {
2460                OpenApiSchemaType::Object => {
2461                    // Double-check for dynamic JSON pattern even for inferred objects
2462                    if self.should_use_dynamic_json(schema) {
2463                        return Ok(SchemaType::Primitive {
2464                            rust_type: "serde_json::Value".to_string(),
2465                            serde_with: None,
2466                        });
2467                    }
2468                    return self.analyze_object_schema(schema, dependencies);
2469                }
2470                OpenApiSchemaType::Array => {
2471                    let context_name = if let Some(prop_name) = property_name {
2472                        // Use property name for context
2473                        let prop_pascal = self.to_pascal_case(prop_name);
2474                        format!(
2475                            "{}{}",
2476                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2477                            prop_pascal
2478                        )
2479                    } else {
2480                        // Fallback to generic name
2481                        "ArrayItem".to_string()
2482                    };
2483                    return self.analyze_array_schema(schema, &context_name, dependencies);
2484                }
2485                OpenApiSchemaType::String => {
2486                    if let Some(enum_values) = schema.details().string_enum_values() {
2487                        return Ok(SchemaType::StringEnum {
2488                            values: enum_values,
2489                        });
2490                    } else {
2491                        return Ok(SchemaType::Primitive {
2492                            rust_type: "String".to_string(),
2493                            serde_with: None,
2494                        });
2495                    }
2496                }
2497                _ => {
2498                    // Handle other inferred types
2499                    let rust_type = self.openapi_type_to_rust_type(inferred_type, schema.details());
2500                    return Ok(SchemaType::Primitive {
2501                        rust_type,
2502                        serde_with: None,
2503                    });
2504                }
2505            }
2506        }
2507
2508        Ok(SchemaType::Primitive {
2509            rust_type: "serde_json::Value".to_string(),
2510            serde_with: None,
2511        })
2512    }
2513
2514    fn analyze_allof_composition(
2515        &mut self,
2516        all_of_schemas: &[Schema],
2517        dependencies: &mut HashSet<String>,
2518    ) -> Result<SchemaType> {
2519        // A reference plus annotation-only siblings is still a direct type
2520        // alias. AWS-style specs frequently encode property descriptions as
2521        // `allOf: [$ref, { description: ... }]`; recursively expanding a
2522        // self-reference in that shape can otherwise recurse forever.
2523        let referenced_targets = all_of_schemas
2524            .iter()
2525            .filter_map(|schema| schema.reference())
2526            .filter_map(|reference| self.extract_schema_name(reference))
2527            .collect::<Vec<_>>();
2528        let only_reference_and_annotations = all_of_schemas.iter().all(|schema| {
2529            if schema.reference().is_some() {
2530                return true;
2531            }
2532            serde_json::to_value(schema)
2533                .ok()
2534                .and_then(|value| value.as_object().cloned())
2535                .is_some_and(|object| {
2536                    object.keys().all(|key| {
2537                        matches!(
2538                            key.as_str(),
2539                            "title"
2540                                | "description"
2541                                | "deprecated"
2542                                | "readOnly"
2543                                | "writeOnly"
2544                                | "examples"
2545                                | "example"
2546                                | "externalDocs"
2547                                | "xml"
2548                                | "$comment"
2549                        ) || key.starts_with("x-")
2550                    })
2551                })
2552        });
2553        if referenced_targets.len() == 1 && only_reference_and_annotations {
2554            let target = referenced_targets[0];
2555            dependencies.insert(target.to_string());
2556            return Ok(SchemaType::Reference {
2557                target: target.to_string(),
2558            });
2559        }
2560
2561        // AllOf represents schema composition - merge all schemas into one
2562        let mut merged_properties = BTreeMap::new();
2563        let mut merged_required = HashSet::new();
2564        let mut descriptions = Vec::new();
2565
2566        // Save the current schema context to restore it when analyzing properties
2567        let current_context = self.current_schema_name.clone();
2568
2569        for schema in all_of_schemas {
2570            match schema {
2571                Schema::Reference { reference, .. } => {
2572                    // Add dependency on referenced schema
2573                    if let Some(target) = self.extract_schema_name(reference) {
2574                        dependencies.insert(target.to_string());
2575
2576                        // First ensure the referenced schema is analyzed
2577                        let analyzed_ref = self.analyze_schema(target)?;
2578
2579                        // Now merge the analyzed schema's properties
2580                        match &analyzed_ref.schema_type {
2581                            SchemaType::Object {
2582                                properties,
2583                                required,
2584                                ..
2585                            } => {
2586                                // Merge properties from the analyzed schema
2587                                for (prop_name, prop_info) in properties {
2588                                    merged_properties.insert(prop_name.clone(), prop_info.clone());
2589                                }
2590                                // Merge required fields
2591                                for req in required {
2592                                    merged_required.insert(req.clone());
2593                                }
2594                            }
2595                            _ => {
2596                                // If the referenced schema is not an object, fall back to raw merge
2597                                if let Some(ref_schema) = self.schemas.get(target).cloned() {
2598                                    self.merge_schema_into_properties(
2599                                        &ref_schema,
2600                                        &mut merged_properties,
2601                                        &mut merged_required,
2602                                        dependencies,
2603                                    )?;
2604                                }
2605                            }
2606                        }
2607                    }
2608                }
2609                Schema::Typed {
2610                    schema_type: OpenApiSchemaType::Object,
2611                    ..
2612                }
2613                | Schema::Untyped { .. } => {
2614                    // Restore the original context when analyzing inline properties
2615                    let saved_context = self.current_schema_name.clone();
2616                    self.current_schema_name = current_context.clone();
2617
2618                    // Merge object properties directly
2619                    self.merge_schema_into_properties(
2620                        schema,
2621                        &mut merged_properties,
2622                        &mut merged_required,
2623                        dependencies,
2624                    )?;
2625
2626                    // Restore the previous context
2627                    self.current_schema_name = saved_context;
2628                }
2629                _ => {
2630                    // For non-object typed schemas in allOf, try to merge them as well
2631                    // This handles cases like allOf with enum or string constraints
2632                    self.merge_schema_into_properties(
2633                        schema,
2634                        &mut merged_properties,
2635                        &mut merged_required,
2636                        dependencies,
2637                    )?;
2638                }
2639            }
2640
2641            // Collect descriptions
2642            if let Some(desc) = &schema.details().description {
2643                descriptions.push(desc.clone());
2644            }
2645        }
2646
2647        // If we successfully merged properties, return an object
2648        if !merged_properties.is_empty() {
2649            Ok(SchemaType::Object {
2650                properties: merged_properties,
2651                required: merged_required,
2652                additional_properties: ObjectAdditionalProperties::Forbidden,
2653            })
2654        } else {
2655            // Fall back to composition if we couldn't merge
2656            Ok(SchemaType::Composition {
2657                schemas: all_of_schemas
2658                    .iter()
2659                    .filter_map(|s| {
2660                        if let Some(ref_str) = s.reference() {
2661                            if let Some(target) = self.extract_schema_name(ref_str) {
2662                                dependencies.insert(target.to_string());
2663                                Some(SchemaRef {
2664                                    target: target.to_string(),
2665                                    nullable: false,
2666                                })
2667                            } else {
2668                                None
2669                            }
2670                        } else {
2671                            None
2672                        }
2673                    })
2674                    .collect(),
2675            })
2676        }
2677    }
2678
2679    fn merge_schema_into_properties(
2680        &mut self,
2681        schema: &Schema,
2682        merged_properties: &mut BTreeMap<String, PropertyInfo>,
2683        merged_required: &mut HashSet<String>,
2684        dependencies: &mut HashSet<String>,
2685    ) -> Result<()> {
2686        let details = schema.details();
2687
2688        // Merge properties
2689        if let Some(properties) = &details.properties {
2690            for (prop_name, prop_schema) in properties {
2691                let prop_type = self.analyze_property_schema_with_context(
2692                    prop_schema,
2693                    Some(prop_name),
2694                    dependencies,
2695                )?;
2696                let prop_details = prop_schema.details();
2697
2698                // Properties merged through allOf composition must go through
2699                // the same nullability check as plain object properties.
2700                // Real hits: OpenAI Response.incomplete_details (anyOf-with-null,
2701                // openapi-generator-bgo) and RunPod Pod.startedAt / Pod.template
2702                // (3.1 type-array, openapi-generator-dsu) — the latter arrive
2703                // as `null` from the live API for any pod that hasn't started.
2704                let nullable = prop_schema.is_nullable_any();
2705                merged_properties.insert(
2706                    prop_name.clone(),
2707                    PropertyInfo {
2708                        schema_type: prop_type,
2709                        nullable,
2710                        description: prop_details.description.clone(),
2711                        default: prop_details.default.clone(),
2712                        serde_attrs: Vec::new(),
2713                        constraints: PropertyConstraints::from_schema_details(prop_details),
2714                    },
2715                );
2716            }
2717        }
2718
2719        // Merge required fields
2720        if let Some(required) = &details.required {
2721            for field in required {
2722                merged_required.insert(field.clone());
2723            }
2724        }
2725
2726        Ok(())
2727    }
2728
2729    fn analyze_oneof_union(
2730        &mut self,
2731        one_of_schemas: &[Schema],
2732        discriminator: Option<&crate::openapi::Discriminator>,
2733        parent_name: &str,
2734        dependencies: &mut HashSet<String>,
2735    ) -> Result<SchemaType> {
2736        // Pattern: nullable [Type, null] — return the non-null type directly.
2737        // The nullable bit is recorded at the property level via is_nullable_pattern().
2738        if one_of_schemas.len() == 2 {
2739            let null_count = one_of_schemas
2740                .iter()
2741                .filter(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2742                .count();
2743            if null_count == 1 {
2744                if let Some(non_null) = one_of_schemas
2745                    .iter()
2746                    .find(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2747                {
2748                    return self
2749                        .analyze_schema_value(non_null, parent_name)
2750                        .map(|a| a.schema_type);
2751                }
2752            }
2753        }
2754
2755        // If there's no discriminator, we should create an untagged union
2756        if discriminator.is_none() {
2757            // Handle untagged unions (oneOf without discriminator)
2758            return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
2759        }
2760
2761        // Bug openapi-generator-dpd: if any branch resolves to a non-object
2762        // schema (e.g. a string-enum like ToolChoiceOptions), serde cannot
2763        // deserialize it via an internally-tagged enum because there is no
2764        // JSON object to read the tag from. Fall back to an untagged union
2765        // so the scalar branch can still match.
2766        if one_of_schemas
2767            .iter()
2768            .any(|s| !self.branch_resolves_to_object(s))
2769        {
2770            return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
2771        }
2772
2773        // This is a discriminated union
2774        let discriminator_field = discriminator
2775            .ok_or_else(|| {
2776                GeneratorError::InvalidDiscriminator(
2777                    "expected discriminator after guard check".to_string(),
2778                )
2779            })?
2780            .property_name
2781            .clone();
2782
2783        let mut variants = Vec::new();
2784        let mut used_variant_names = std::collections::HashSet::new();
2785
2786        for variant_schema in one_of_schemas {
2787            // Check if this is a direct reference, recursive reference, or an allOf wrapper with a reference
2788            let ref_info = if let Some(ref_str) = variant_schema.reference() {
2789                Some((ref_str, false))
2790            } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2791                Some((recursive_ref, true))
2792            } else if let Schema::AllOf { all_of, .. } = variant_schema {
2793                // Check if this is an allOf with a single reference
2794                if all_of.len() == 1 {
2795                    if let Some(ref_str) = all_of[0].reference() {
2796                        Some((ref_str, false))
2797                    } else {
2798                        all_of[0]
2799                            .recursive_reference()
2800                            .map(|recursive_ref| (recursive_ref, true))
2801                    }
2802                } else {
2803                    None
2804                }
2805            } else {
2806                None
2807            };
2808
2809            if let Some((ref_str, is_recursive)) = ref_info {
2810                let schema_name = if is_recursive && ref_str == "#" {
2811                    // Handle recursive reference to the schema with recursiveAnchor
2812                    self.find_recursive_anchor_schema()
2813                        .or_else(|| self.current_schema_name.clone())
2814                        .unwrap_or_else(|| "CompoundFilter".to_string())
2815                } else {
2816                    self.extract_schema_name(ref_str)
2817                        .map(|s| s.to_string())
2818                        .unwrap_or_else(|| "UnknownRef".to_string())
2819                };
2820
2821                if !schema_name.is_empty() {
2822                    dependencies.insert(schema_name.clone());
2823
2824                    // Determine discriminator value with priority order:
2825                    // 1. Explicit mapping in discriminator
2826                    // 2. Extract from referenced schema
2827                    // 3. Generate from schema name
2828                    let discriminator_value = if let Some(disc) = discriminator {
2829                        if let Some(mappings) = &disc.mapping {
2830                            // Find the mapping key that points to this schema reference
2831                            // Mapping format is: "discriminator_value" -> "#/components/schemas/SchemaName"
2832                            mappings
2833                                .iter()
2834                                .find(|(_, target_ref)| {
2835                                    // Check if this mapping target matches our reference
2836                                    target_ref.as_str() == ref_str
2837                                        || self
2838                                            .extract_schema_name(target_ref)
2839                                            .map(|s| s.to_string())
2840                                            == Some(schema_name.clone())
2841                                })
2842                                .map(|(key, _)| key.clone())
2843                                .unwrap_or_else(|| {
2844                                    self.fallback_discriminator_value_for_field(
2845                                        &schema_name,
2846                                        &discriminator_field,
2847                                    )
2848                                })
2849                        } else {
2850                            self.fallback_discriminator_value_for_field(
2851                                &schema_name,
2852                                &discriminator_field,
2853                            )
2854                        }
2855                    } else {
2856                        self.fallback_discriminator_value_for_field(
2857                            &schema_name,
2858                            &discriminator_field,
2859                        )
2860                    };
2861
2862                    // Generate Rust-friendly variant name and ensure uniqueness
2863                    let base_name = self.to_rust_variant_name(&schema_name);
2864                    let rust_name =
2865                        self.ensure_unique_variant_name(base_name, &mut used_variant_names);
2866
2867                    // Use the discriminator value as-is from the schema
2868                    let final_discriminator_value = discriminator_value;
2869
2870                    variants.push(UnionVariant {
2871                        rust_name,
2872                        type_name: schema_name,
2873                        discriminator_value: final_discriminator_value,
2874                        schema_ref: ref_str.to_string(),
2875                    });
2876                }
2877            } else {
2878                // Handle inline schemas in oneOf
2879                let variant_index = variants.len();
2880                let inline_type_name =
2881                    self.generate_inline_type_name(variant_schema, variant_index);
2882
2883                // Try to extract discriminator value from inline schema
2884                let discriminator_value = if let Some(disc) = discriminator {
2885                    if let Some(mappings) = &disc.mapping {
2886                        // Look for mapping that points to this inline variant by index
2887                        mappings
2888                            .iter()
2889                            .find(|(_, target_ref)| {
2890                                target_ref.contains(&format!("variant_{variant_index}"))
2891                            })
2892                            .map(|(key, _)| key.clone())
2893                            .unwrap_or_else(|| {
2894                                self.extract_inline_discriminator_value(
2895                                    variant_schema,
2896                                    &discriminator_field,
2897                                    variant_index,
2898                                )
2899                            })
2900                    } else {
2901                        self.extract_inline_discriminator_value(
2902                            variant_schema,
2903                            &discriminator_field,
2904                            variant_index,
2905                        )
2906                    }
2907                } else {
2908                    self.extract_inline_discriminator_value(
2909                        variant_schema,
2910                        &discriminator_field,
2911                        variant_index,
2912                    )
2913                };
2914
2915                // Generate Rust-friendly variant name based on discriminator or fallback to generic
2916                let base_name = if discriminator_value.starts_with("variant_") {
2917                    format!("Variant{variant_index}")
2918                } else {
2919                    // Convert discriminator value to a meaningful Rust variant name
2920                    let clean_name = self.discriminator_to_variant_name(&discriminator_value);
2921                    self.to_rust_variant_name(&clean_name)
2922                };
2923                let rust_name = self.ensure_unique_variant_name(base_name, &mut used_variant_names);
2924
2925                // Use the discriminator value as-is from the schema
2926                let final_discriminator_value = discriminator_value;
2927
2928                variants.push(UnionVariant {
2929                    rust_name,
2930                    type_name: inline_type_name.clone(),
2931                    discriminator_value: final_discriminator_value,
2932                    schema_ref: format!("inline_{variant_index}"),
2933                });
2934
2935                // Store inline schema for later analysis and generation
2936                self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
2937            }
2938        }
2939
2940        if variants.is_empty() {
2941            // If we couldn't create a discriminated union, fall back to an untagged union
2942            // This handles cases where oneOf contains references or inline schemas without proper discriminators
2943            let mut union_variants = Vec::new();
2944
2945            for (variant_index, variant_schema) in one_of_schemas.iter().enumerate() {
2946                // First check if it's a reference or recursive reference
2947                if let Some(ref_str) = variant_schema.reference() {
2948                    if let Some(schema_name) = self.extract_schema_name(ref_str) {
2949                        dependencies.insert(schema_name.to_string());
2950                        union_variants.push(SchemaRef {
2951                            target: schema_name.to_string(),
2952                            nullable: false,
2953                        });
2954                    }
2955                } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2956                    let schema_name = if recursive_ref == "#" {
2957                        // Handle recursive reference to the schema with recursiveAnchor
2958                        self.find_recursive_anchor_schema()
2959                            .or_else(|| self.current_schema_name.clone())
2960                            .unwrap_or_else(|| "CompoundFilter".to_string())
2961                    } else {
2962                        self.extract_schema_name(recursive_ref)
2963                            .map(|s| s.to_string())
2964                            .unwrap_or_else(|| "RecursiveType".to_string())
2965                    };
2966                    dependencies.insert(schema_name.clone());
2967                    union_variants.push(SchemaRef {
2968                        target: schema_name,
2969                        nullable: false,
2970                    });
2971                } else {
2972                    // Handle inline schemas by creating type aliases or using primitive types directly
2973                    let inline_name = self.generate_context_aware_name(
2974                        parent_name,
2975                        "InlineVariant",
2976                        variant_index,
2977                        Some(variant_schema),
2978                    );
2979                    let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
2980                    let variant_type = analyzed.schema_type;
2981
2982                    // Add dependencies from the analyzed schema
2983                    for dep in &analyzed.dependencies {
2984                        dependencies.insert(dep.clone());
2985                    }
2986
2987                    match &variant_type {
2988                        // For primitive types, we can use them directly in the union
2989                        SchemaType::Primitive { rust_type, .. } => {
2990                            union_variants.push(SchemaRef {
2991                                target: rust_type.clone(),
2992                                nullable: false,
2993                            });
2994                        }
2995                        // For arrays, check if we can determine the item type
2996                        SchemaType::Array { item_type } => {
2997                            match item_type.as_ref() {
2998                                SchemaType::Primitive { rust_type, .. } => {
2999                                    let type_name = format!("Vec<{rust_type}>");
3000                                    union_variants.push(SchemaRef {
3001                                        target: type_name,
3002                                        nullable: false,
3003                                    });
3004                                }
3005                                SchemaType::Reference { target } => {
3006                                    let type_name = format!("Vec<{target}>");
3007                                    union_variants.push(SchemaRef {
3008                                        target: type_name,
3009                                        nullable: false,
3010                                    });
3011                                }
3012                                _ => {
3013                                    // For other array types, create an inline type
3014                                    let inline_type_name = self.generate_context_aware_name(
3015                                        parent_name,
3016                                        "Variant",
3017                                        variant_index,
3018                                        None,
3019                                    );
3020                                    self.add_inline_schema(
3021                                        &inline_type_name,
3022                                        variant_schema,
3023                                        dependencies,
3024                                    )?;
3025                                    union_variants.push(SchemaRef {
3026                                        target: inline_type_name,
3027                                        nullable: false,
3028                                    });
3029                                }
3030                            }
3031                        }
3032                        // For reference types, use the reference target directly
3033                        SchemaType::Reference { target } => {
3034                            union_variants.push(SchemaRef {
3035                                target: target.clone(),
3036                                nullable: false,
3037                            });
3038                        }
3039                        // For other complex types, create an inline type
3040                        _ => {
3041                            let inline_type_name =
3042                                format!("{}Variant{}", parent_name, variant_index + 1);
3043                            self.add_inline_schema(
3044                                &inline_type_name,
3045                                variant_schema,
3046                                dependencies,
3047                            )?;
3048                            union_variants.push(SchemaRef {
3049                                target: inline_type_name,
3050                                nullable: false,
3051                            });
3052                        }
3053                    }
3054                }
3055            }
3056
3057            if !union_variants.is_empty() {
3058                return Ok(SchemaType::Union {
3059                    variants: union_variants,
3060                });
3061            }
3062
3063            // Only fall back to serde_json::Value if we truly can't analyze the union
3064            return Ok(SchemaType::Primitive {
3065                rust_type: "serde_json::Value".to_string(),
3066                serde_with: None,
3067            });
3068        }
3069
3070        Ok(SchemaType::DiscriminatedUnion {
3071            discriminator_field,
3072            variants,
3073        })
3074    }
3075
3076    fn analyze_untagged_oneof_union(
3077        &mut self,
3078        one_of_schemas: &[Schema],
3079        parent_name: &str,
3080        dependencies: &mut HashSet<String>,
3081    ) -> Result<SchemaType> {
3082        // Drop {"type": "null"} variants. They mean "may be null" and are surfaced
3083        // as Option<T> at the property level — including them here produces a junk
3084        // `SerdeJsonValue(serde_json::Value)` variant.
3085        let filtered: Vec<&Schema> = one_of_schemas
3086            .iter()
3087            .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
3088            .collect();
3089
3090        // If filtering leaves a single variant, return its analyzed type directly.
3091        if filtered.len() == 1 {
3092            return self
3093                .analyze_schema_value(filtered[0], parent_name)
3094                .map(|a| a.schema_type);
3095        }
3096
3097        let mut union_variants = Vec::new();
3098
3099        for (variant_index, variant_schema) in filtered.iter().copied().enumerate() {
3100            // First check if it's a reference or recursive reference
3101            if let Some(ref_str) = variant_schema.reference() {
3102                if let Some(schema_name) = self.extract_schema_name(ref_str) {
3103                    dependencies.insert(schema_name.to_string());
3104                    union_variants.push(SchemaRef {
3105                        target: schema_name.to_string(),
3106                        nullable: false,
3107                    });
3108                }
3109            } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
3110                let schema_name = if recursive_ref == "#" {
3111                    // Handle recursive reference to the schema with recursiveAnchor
3112                    self.find_recursive_anchor_schema()
3113                        .or_else(|| self.current_schema_name.clone())
3114                        .unwrap_or_else(|| "CompoundFilter".to_string())
3115                } else {
3116                    self.extract_schema_name(recursive_ref)
3117                        .map(|s| s.to_string())
3118                        .unwrap_or_else(|| "RecursiveType".to_string())
3119                };
3120                dependencies.insert(schema_name.clone());
3121                union_variants.push(SchemaRef {
3122                    target: schema_name,
3123                    nullable: false,
3124                });
3125            } else {
3126                // Handle inline schemas by creating type aliases or using primitive types directly
3127                let inline_name = self.generate_context_aware_name(
3128                    parent_name,
3129                    "InlineVariant",
3130                    variant_index,
3131                    Some(variant_schema),
3132                );
3133                let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
3134                let variant_type = analyzed.schema_type;
3135
3136                // Add dependencies from the analyzed schema
3137                for dep in &analyzed.dependencies {
3138                    dependencies.insert(dep.clone());
3139                }
3140
3141                match &variant_type {
3142                    // For primitive types, we can use them directly in the union
3143                    SchemaType::Primitive { rust_type, .. } => {
3144                        union_variants.push(SchemaRef {
3145                            target: rust_type.clone(),
3146                            nullable: false,
3147                        });
3148                    }
3149                    // For arrays, check if we can determine the item type
3150                    SchemaType::Array { item_type } => {
3151                        match item_type.as_ref() {
3152                            SchemaType::Primitive { rust_type, .. } => {
3153                                let type_name = format!("Vec<{rust_type}>");
3154                                union_variants.push(SchemaRef {
3155                                    target: type_name,
3156                                    nullable: false,
3157                                });
3158                            }
3159                            SchemaType::Reference { target } => {
3160                                let type_name = format!("Vec<{target}>");
3161                                union_variants.push(SchemaRef {
3162                                    target: type_name,
3163                                    nullable: false,
3164                                });
3165                            }
3166                            // Handle arrays of arrays (e.g., Vec<Vec<i64>>)
3167                            SchemaType::Array {
3168                                item_type: inner_item_type,
3169                            } => {
3170                                match inner_item_type.as_ref() {
3171                                    SchemaType::Primitive { rust_type, .. } => {
3172                                        let type_name = format!("Vec<Vec<{rust_type}>>");
3173                                        union_variants.push(SchemaRef {
3174                                            target: type_name,
3175                                            nullable: false,
3176                                        });
3177                                    }
3178                                    SchemaType::Reference { target } => {
3179                                        let type_name = format!("Vec<Vec<{target}>>");
3180                                        union_variants.push(SchemaRef {
3181                                            target: type_name,
3182                                            nullable: false,
3183                                        });
3184                                    }
3185                                    _ => {
3186                                        // For deeper nesting, create an inline type
3187                                        let inline_type_name = self.generate_context_aware_name(
3188                                            parent_name,
3189                                            "Variant",
3190                                            variant_index,
3191                                            None,
3192                                        );
3193                                        self.add_inline_schema(
3194                                            &inline_type_name,
3195                                            variant_schema,
3196                                            dependencies,
3197                                        )?;
3198                                        union_variants.push(SchemaRef {
3199                                            target: inline_type_name,
3200                                            nullable: false,
3201                                        });
3202                                    }
3203                                }
3204                            }
3205                            _ => {
3206                                // For other array types, create an inline type
3207                                let inline_type_name = self.generate_context_aware_name(
3208                                    parent_name,
3209                                    "Variant",
3210                                    variant_index,
3211                                    None,
3212                                );
3213                                self.add_inline_schema(
3214                                    &inline_type_name,
3215                                    variant_schema,
3216                                    dependencies,
3217                                )?;
3218                                union_variants.push(SchemaRef {
3219                                    target: inline_type_name,
3220                                    nullable: false,
3221                                });
3222                            }
3223                        }
3224                    }
3225                    // For reference types, use the reference target directly
3226                    SchemaType::Reference { target } => {
3227                        union_variants.push(SchemaRef {
3228                            target: target.clone(),
3229                            nullable: false,
3230                        });
3231                    }
3232                    // For other complex types, create an inline type
3233                    _ => {
3234                        let inline_type_name = self.generate_context_aware_name(
3235                            parent_name,
3236                            "Variant",
3237                            variant_index,
3238                            None,
3239                        );
3240                        self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
3241                        union_variants.push(SchemaRef {
3242                            target: inline_type_name,
3243                            nullable: false,
3244                        });
3245                    }
3246                }
3247            }
3248        }
3249
3250        if !union_variants.is_empty() {
3251            return Ok(SchemaType::Union {
3252                variants: union_variants,
3253            });
3254        }
3255
3256        // Only fall back to serde_json::Value if we truly can't analyze the union
3257        Ok(SchemaType::Primitive {
3258            rust_type: "serde_json::Value".to_string(),
3259            serde_with: None,
3260        })
3261    }
3262
3263    fn add_inline_schema(
3264        &mut self,
3265        type_name: &str,
3266        schema: &Schema,
3267        dependencies: &mut HashSet<String>,
3268    ) -> Result<()> {
3269        // For primitive types, we need to ensure they are stored as type aliases
3270        if let Some(schema_type) = schema.schema_type() {
3271            match schema_type {
3272                OpenApiSchemaType::String
3273                | OpenApiSchemaType::Integer
3274                | OpenApiSchemaType::Number
3275                | OpenApiSchemaType::Boolean => {
3276                    let rust_type =
3277                        self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
3278
3279                    // Store as a type alias
3280                    self.resolved_cache.insert(
3281                        type_name.to_string(),
3282                        AnalyzedSchema {
3283                            name: type_name.to_string(),
3284                            original: serde_json::to_value(schema).unwrap_or(Value::Null),
3285                            schema_type: SchemaType::Primitive {
3286                                rust_type,
3287                                serde_with: None,
3288                            },
3289                            dependencies: HashSet::new(),
3290                            nullable: false,
3291                            description: schema.details().description.clone(),
3292                            default: None,
3293                        },
3294                    );
3295                    return Ok(());
3296                }
3297                _ => {}
3298            }
3299        }
3300
3301        // For non-primitive types, analyze the inline schema and add it to our collection
3302        // Set current_schema_name so nested inline properties (enums, unions, objects)
3303        // get named with the correct parent context instead of inheriting a stale name
3304        let previous_schema_name = self.current_schema_name.take();
3305        self.current_schema_name = Some(type_name.to_string());
3306        let analyzed = self.analyze_schema_value(schema, type_name)?;
3307        self.current_schema_name = previous_schema_name;
3308
3309        // Add to resolved cache so it can be generated
3310        self.resolved_cache.insert(type_name.to_string(), analyzed);
3311
3312        // Add dependencies
3313        if let Some(cached) = self.resolved_cache.get(type_name) {
3314            for dep in &cached.dependencies {
3315                dependencies.insert(dep.clone());
3316            }
3317        }
3318
3319        Ok(())
3320    }
3321
3322    fn extract_inline_discriminator_value(
3323        &self,
3324        schema: &Schema,
3325        discriminator_field: &str,
3326        variant_index: usize,
3327    ) -> String {
3328        // Try to extract discriminator value from inline schema properties
3329        if let Some(properties) = &schema.details().properties {
3330            if let Some(discriminator_prop) = properties.get(discriminator_field) {
3331                // Check for enum with single value
3332                if let Some(enum_values) = &discriminator_prop.details().enum_values {
3333                    if enum_values.len() == 1 {
3334                        if let Some(value) = enum_values[0].as_str() {
3335                            return value.to_string();
3336                        }
3337                    }
3338                }
3339                // Check for const value in extra fields
3340                if let Some(const_value) = discriminator_prop.details().extra.get("const") {
3341                    if let Some(value) = const_value.as_str() {
3342                        return value.to_string();
3343                    }
3344                }
3345                // Check for const value in the discriminator_prop.details().const_value
3346                if let Some(const_value) = &discriminator_prop.details().const_value {
3347                    if let Some(value) = const_value.as_str() {
3348                        return value.to_string();
3349                    }
3350                }
3351            }
3352        }
3353
3354        // Try to infer from schema structure and properties
3355        if let Some(inferred_name) = self.infer_variant_name_from_structure(schema, variant_index) {
3356            return inferred_name;
3357        }
3358
3359        // Fall back to generic variant name
3360        format!("variant_{variant_index}")
3361    }
3362
3363    fn infer_variant_name_from_structure(
3364        &self,
3365        schema: &Schema,
3366        _variant_index: usize,
3367    ) -> Option<String> {
3368        let details = schema.details();
3369
3370        // Strategy 1: Look for unique property combinations that suggest the variant type
3371        if let Some(properties) = &details.properties {
3372            // Common patterns for content blocks
3373            if properties.contains_key("text") && properties.len() <= 3 {
3374                return Some("text".to_string());
3375            }
3376            if properties.contains_key("image") || properties.contains_key("source") {
3377                return Some("image".to_string());
3378            }
3379            if properties.contains_key("document") {
3380                return Some("document".to_string());
3381            }
3382            if properties.contains_key("tool_use_id") || properties.contains_key("tool_result") {
3383                return Some("tool_result".to_string());
3384            }
3385            if properties.contains_key("content") && properties.contains_key("is_error") {
3386                return Some("tool_result".to_string());
3387            }
3388            if properties.contains_key("partial_json") {
3389                return Some("partial_json".to_string());
3390            }
3391
3392            // Strategy 2: Look for properties that hint at the variant purpose
3393            let property_names: Vec<&String> = properties.keys().collect();
3394
3395            // Try to find the most descriptive property name
3396            for prop_name in &property_names {
3397                if prop_name.contains("result") {
3398                    return Some("result".to_string());
3399                }
3400                if prop_name.contains("error") {
3401                    return Some("error".to_string());
3402                }
3403                if prop_name.contains("content") && property_names.len() <= 2 {
3404                    return Some("content".to_string());
3405                }
3406            }
3407
3408            // Strategy 3: Use the most significant unique property
3409            let significant_props = property_names
3410                .iter()
3411                .filter(|&name| !["type", "id", "cache_control"].contains(&name.as_str()))
3412                .collect::<Vec<_>>();
3413
3414            if significant_props.len() == 1 {
3415                return Some((*significant_props[0]).clone());
3416            }
3417        }
3418
3419        // Strategy 4: Look at description for hints
3420        if let Some(description) = &details.description {
3421            let desc_lower = description.to_lowercase();
3422            if desc_lower.contains("text") && desc_lower.len() < 100 {
3423                return Some("text".to_string());
3424            }
3425            if desc_lower.contains("image") {
3426                return Some("image".to_string());
3427            }
3428            if desc_lower.contains("document") {
3429                return Some("document".to_string());
3430            }
3431            if desc_lower.contains("tool") && desc_lower.contains("result") {
3432                return Some("tool_result".to_string());
3433            }
3434        }
3435
3436        None
3437    }
3438
3439    fn discriminator_to_variant_name(&self, discriminator: &str) -> String {
3440        // Convert discriminator values to PascalCase variant names using general rules
3441        if discriminator.is_empty() {
3442            return "Variant".to_string();
3443        }
3444
3445        let mut result = String::new();
3446        let mut next_upper = true;
3447
3448        for c in discriminator.chars() {
3449            match c {
3450                'a'..='z' => {
3451                    if next_upper {
3452                        result.push(c.to_ascii_uppercase());
3453                        next_upper = false;
3454                    } else {
3455                        result.push(c);
3456                    }
3457                }
3458                'A'..='Z' => {
3459                    result.push(c);
3460                    next_upper = false;
3461                }
3462                '0'..='9' => {
3463                    result.push(c);
3464                    next_upper = false;
3465                }
3466                '_' | '-' | '.' | ' ' | '/' | '\\' => {
3467                    // Word separators - next char should be uppercase
3468                    next_upper = true;
3469                }
3470                _ => {
3471                    // Other special characters - treat as word boundary
3472                    next_upper = true;
3473                }
3474            }
3475        }
3476
3477        // Ensure it starts with a letter
3478        if result.is_empty() || result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
3479            result = format!("Variant{result}");
3480        }
3481
3482        result
3483    }
3484
3485    fn ensure_unique_variant_name(
3486        &self,
3487        base_name: String,
3488        used_names: &mut std::collections::HashSet<String>,
3489    ) -> String {
3490        let mut candidate = base_name.clone();
3491        let mut counter = 1;
3492
3493        while used_names.contains(&candidate) {
3494            counter += 1;
3495            candidate = format!("{base_name}{counter}");
3496        }
3497
3498        used_names.insert(candidate.clone());
3499        candidate
3500    }
3501
3502    fn generate_inline_type_name(&self, schema: &Schema, variant_index: usize) -> String {
3503        // Try to generate a meaningful name for inline schemas
3504        if let Some(meaningful_name) = self.infer_type_name_from_structure(schema) {
3505            return meaningful_name;
3506        }
3507
3508        // Fallback to context-aware name
3509        let context = self.current_schema_name.as_deref().unwrap_or("Inline");
3510        self.generate_context_aware_name(context, "Variant", variant_index, Some(schema))
3511    }
3512
3513    fn infer_type_name_from_structure(&self, schema: &Schema) -> Option<String> {
3514        let details = schema.details();
3515
3516        // Strategy 1: Use description if it's short and descriptive
3517        if let Some(description) = &details.description {
3518            if let Some(name_from_desc) = self.extract_type_name_from_description(description) {
3519                return Some(name_from_desc);
3520            }
3521        }
3522
3523        // Strategy 2: Use the most significant property name as the type identifier
3524        if let Some(properties) = &details.properties {
3525            if let Some(name_from_props) = self.extract_type_name_from_properties(properties) {
3526                return Some(format!("{name_from_props}Block"));
3527            }
3528        }
3529
3530        None
3531    }
3532
3533    fn extract_type_name_from_description(&self, description: &str) -> Option<String> {
3534        // Only use descriptions that are short and likely to be type identifiers
3535        if description.len() > 100 || description.contains('\n') {
3536            return None;
3537        }
3538
3539        // Extract the first meaningful word(s) from the description
3540        let words: Vec<&str> = description
3541            .split_whitespace()
3542            .take(2) // Only take first 2 words to avoid long names
3543            .filter(|word| {
3544                let w = word.to_lowercase();
3545                word.len() > 2
3546                    && ![
3547                        "the", "and", "for", "with", "that", "this", "are", "can", "will", "was",
3548                    ]
3549                    .contains(&w.as_str())
3550            })
3551            .collect();
3552
3553        if words.is_empty() {
3554            return None;
3555        }
3556
3557        // Convert to PascalCase using our existing logic
3558        let combined = words.join("_");
3559        let pascal_name = self.discriminator_to_variant_name(&combined);
3560
3561        // Add suffix if it doesn't already have one
3562        if !pascal_name.ends_with("Content")
3563            && !pascal_name.ends_with("Block")
3564            && !pascal_name.ends_with("Type")
3565        {
3566            Some(format!("{pascal_name}Content"))
3567        } else {
3568            Some(pascal_name)
3569        }
3570    }
3571
3572    fn extract_type_name_from_properties(
3573        &self,
3574        properties: &std::collections::BTreeMap<String, crate::openapi::Schema>,
3575    ) -> Option<String> {
3576        // Get property names, excluding common structural properties
3577        let significant_props: Vec<&String> = properties
3578            .keys()
3579            .filter(|name| !["type", "id", "cache_control"].contains(&name.as_str()))
3580            .collect();
3581
3582        if significant_props.is_empty() {
3583            return None;
3584        }
3585
3586        // Strategy 1: If there's only one significant property, use it
3587        if significant_props.len() == 1 {
3588            let prop_name = significant_props[0];
3589            return Some(self.discriminator_to_variant_name(prop_name));
3590        }
3591
3592        // Strategy 2: Use the first property alphabetically for consistency
3593        // This provides deterministic naming without hardcoded preferences
3594        let mut sorted_props = significant_props.clone();
3595        sorted_props.sort();
3596        if let Some(first_prop) = sorted_props.first() {
3597            return Some(self.discriminator_to_variant_name(first_prop));
3598        }
3599
3600        None
3601    }
3602
3603    fn openapi_type_to_rust_type(
3604        &self,
3605        openapi_type: OpenApiSchemaType,
3606        details: &crate::openapi::SchemaDetails,
3607    ) -> String {
3608        // Q2.0: route through the TypeMapper chokepoint. With the default
3609        // config this produces bit-identical output to the pre-refactor
3610        // match; later Q2.* issues add format-aware branches inside
3611        // TypeMapper without touching this function.
3612        self.type_mapper.map(openapi_type, details).rust_type
3613    }
3614
3615    #[allow(dead_code)]
3616    fn fallback_discriminator_value(&self, schema_name: &str) -> String {
3617        self.fallback_discriminator_value_for_field(schema_name, "type")
3618    }
3619
3620    fn fallback_discriminator_value_for_field(
3621        &self,
3622        schema_name: &str,
3623        field_name: &str,
3624    ) -> String {
3625        // Try to extract from referenced schema first
3626        if let Some(ref_schema) = self.schemas.get(schema_name) {
3627            if let Some(extracted) =
3628                self.extract_discriminator_value_for_field(ref_schema, field_name)
3629            {
3630                return extracted;
3631            }
3632        }
3633
3634        // Fall back to generating from name
3635        self.generate_discriminator_value_from_name(schema_name)
3636    }
3637
3638    fn generate_discriminator_value_from_name(&self, schema_name: &str) -> String {
3639        // Convert schema names like "ResponseCreatedEvent" to "response.created"
3640        let mut result = String::new();
3641        let mut chars = schema_name.chars().peekable();
3642        let mut first = true;
3643
3644        while let Some(c) = chars.next() {
3645            if c.is_uppercase()
3646                && !first
3647                && chars
3648                    .peek()
3649                    .map(|&next| next.is_lowercase())
3650                    .unwrap_or(false)
3651            {
3652                result.push('.');
3653            }
3654            result.push(c.to_ascii_lowercase());
3655            first = false;
3656        }
3657
3658        // Remove common suffixes
3659        if result.ends_with("event") {
3660            result = result[..result.len() - 5].to_string();
3661        }
3662
3663        // Add "response." prefix if it looks like a response event
3664        if schema_name.starts_with("Response") && !result.starts_with("response.") {
3665            result = format!("response.{}", result.trim_start_matches("response"));
3666        }
3667
3668        result
3669    }
3670
3671    fn to_rust_variant_name(&self, schema_name: &str) -> String {
3672        // Convert "ResponseCreatedEvent" to "Created", "UserStatus" to "UserStatus", etc.
3673        let mut name = schema_name;
3674
3675        // Remove common prefixes for cleaner variant names
3676        if name.starts_with("Response") && name.len() > 8 {
3677            name = &name[8..]; // Remove "Response"
3678        }
3679
3680        // Remove common suffixes
3681        if name.ends_with("Event") && name.len() > 5 {
3682            name = &name[..name.len() - 5]; // Remove "Event"
3683        }
3684
3685        // Trim leading and trailing underscores
3686        name = name.trim_matches('_');
3687
3688        // Convert underscores to camel case using our existing function
3689        if name.is_empty() {
3690            schema_name.to_string()
3691        } else {
3692            // Use discriminator_to_variant_name to properly handle underscores
3693            self.discriminator_to_variant_name(name)
3694        }
3695    }
3696
3697    /// Register an inline string enum as a named `StringEnum` schema and
3698    /// return a `Reference` to it. Shared by property-level enums
3699    /// (`{Schema}{Prop}`) and array-item enums (`{Schema}{Prop}Item`).
3700    ///
3701    /// Resolves a name that either matches an existing same-valued
3702    /// enum (dedup) or doesn't collide with a different one.
3703    ///
3704    /// Two distinct inline enums can land on the same primary
3705    /// candidate when a parent schema has a property like
3706    /// `type` that recurs at multiple nesting levels — e.g.
3707    /// Latitude.sh's `plan_data.type = ["plans"]` (the
3708    /// JSON-API resource type) and
3709    /// `plan_data.attributes.specs.drives[].type =
3710    /// ["SSD","HDD","NVME"]` both want to become
3711    /// `PlanDataType`. We must NOT silently overwrite the
3712    /// first registration: that breaks deserialization
3713    /// because both fields end up referencing whichever
3714    /// enum was processed last.
3715    ///
3716    /// Disambiguation strategy: append the PascalCase first
3717    /// enum value (`PlanDataTypeNVME` vs `PlanDataTypePlans`)
3718    /// and, if that's also claimed with different values,
3719    /// fall back to a numeric `_2`, `_3`, … suffix.
3720    fn hoist_inline_string_enum(
3721        &mut self,
3722        schema: &Schema,
3723        enum_values: Vec<String>,
3724        primary_name: String,
3725        dependencies: &mut HashSet<String>,
3726    ) -> SchemaType {
3727        fn matches_values(existing: &AnalyzedSchema, values: &[String]) -> bool {
3728            matches!(
3729                &existing.schema_type,
3730                SchemaType::StringEnum { values: existing_values }
3731                    if existing_values == values
3732            )
3733        }
3734
3735        let mut enum_type_name = primary_name.clone();
3736        let should_insert = match self.resolved_cache.get(&enum_type_name) {
3737            None => true,
3738            Some(existing) if matches_values(existing, &enum_values) => false,
3739            Some(_) => {
3740                // Collision with different values — try a
3741                // value-suffixed name first.
3742                let suffix = enum_values
3743                    .first()
3744                    .map(|v| self.to_pascal_case(v))
3745                    .unwrap_or_else(|| "Variant".to_string());
3746                let candidate = format!("{primary_name}{suffix}");
3747
3748                let resolved = match self.resolved_cache.get(&candidate) {
3749                    None => Some((candidate.clone(), true)),
3750                    Some(existing) if matches_values(existing, &enum_values) => {
3751                        Some((candidate.clone(), false))
3752                    }
3753                    Some(_) => {
3754                        // Walk a numeric suffix until we find
3755                        // a slot that's free or matches.
3756                        let mut found = None;
3757                        for n in 2..1000 {
3758                            let numbered = format!("{candidate}_{n}");
3759                            match self.resolved_cache.get(&numbered) {
3760                                None => {
3761                                    found = Some((numbered, true));
3762                                    break;
3763                                }
3764                                Some(existing) if matches_values(existing, &enum_values) => {
3765                                    found = Some((numbered, false));
3766                                    break;
3767                                }
3768                                Some(_) => continue,
3769                            }
3770                        }
3771                        found
3772                    }
3773                };
3774
3775                let (resolved_name, insert) = resolved.unwrap_or((candidate, true));
3776                enum_type_name = resolved_name;
3777                insert
3778            }
3779        };
3780
3781        // Store the enum as a named schema if this is the
3782        // first time we've seen this exact (name, values) pair.
3783        if should_insert {
3784            self.resolved_cache.insert(
3785                enum_type_name.clone(),
3786                AnalyzedSchema {
3787                    name: enum_type_name.clone(),
3788                    original: serde_json::to_value(schema).unwrap_or(Value::Null),
3789                    schema_type: SchemaType::StringEnum {
3790                        values: enum_values,
3791                    },
3792                    dependencies: HashSet::new(),
3793                    nullable: false,
3794                    description: schema.details().description.clone(),
3795                    default: schema.details().default.clone(),
3796                },
3797            );
3798        }
3799
3800        // Return a reference to the named enum type
3801        dependencies.insert(enum_type_name.clone());
3802        SchemaType::Reference {
3803            target: enum_type_name,
3804        }
3805    }
3806
3807    fn analyze_array_schema(
3808        &mut self,
3809        schema: &Schema,
3810        parent_schema_name: &str,
3811        dependencies: &mut HashSet<String>,
3812    ) -> Result<SchemaType> {
3813        let details = schema.details();
3814
3815        // Check if items field is present
3816        if let Some(items_schema) = &details.items {
3817            // Analyze the item type
3818            let item_type = match items_schema.as_ref() {
3819                Schema::Reference { reference, .. } => {
3820                    // Array of referenced types
3821                    let target = self
3822                        .extract_schema_name(reference)
3823                        .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))?
3824                        .to_string();
3825                    dependencies.insert(target.clone());
3826                    SchemaType::Reference { target }
3827                }
3828                Schema::RecursiveRef { recursive_ref, .. } => {
3829                    // Array of recursive references
3830                    if recursive_ref == "#" {
3831                        // Self-reference to the current schema
3832                        let target = self
3833                            .find_recursive_anchor_schema()
3834                            .unwrap_or_else(|| parent_schema_name.to_string());
3835                        dependencies.insert(target.clone());
3836                        SchemaType::Reference { target }
3837                    } else {
3838                        let target = self
3839                            .extract_schema_name(recursive_ref)
3840                            .unwrap_or("RecursiveType")
3841                            .to_string();
3842                        dependencies.insert(target.clone());
3843                        SchemaType::Reference { target }
3844                    }
3845                }
3846                Schema::Typed { schema_type, .. } => {
3847                    // Array of primitive types
3848                    match schema_type {
3849                        OpenApiSchemaType::String => {
3850                            // Inline string enum in array items — hoist to a
3851                            // named enum (`{Parent}Item`) instead of collapsing
3852                            // to `Vec<String>`.
3853                            match items_schema
3854                                .details()
3855                                .string_enum_values()
3856                                .filter(|values| !values.is_empty())
3857                            {
3858                                Some(values) => self.hoist_inline_string_enum(
3859                                    items_schema,
3860                                    values,
3861                                    format!("{parent_schema_name}Item"),
3862                                    dependencies,
3863                                ),
3864                                None => SchemaType::Primitive {
3865                                    rust_type: "String".to_string(),
3866                                    serde_with: None,
3867                                },
3868                            }
3869                        }
3870                        OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
3871                            let details = items_schema.details();
3872                            let rust_type = self.get_number_rust_type(schema_type.clone(), details);
3873                            SchemaType::Primitive {
3874                                rust_type,
3875                                serde_with: None,
3876                            }
3877                        }
3878                        OpenApiSchemaType::Boolean => SchemaType::Primitive {
3879                            rust_type: "bool".to_string(),
3880                            serde_with: None,
3881                        },
3882                        OpenApiSchemaType::Object => {
3883                            // Inline object in array - create a named schema for it
3884                            let object_type_name = format!("{parent_schema_name}Item");
3885
3886                            // Analyze the object schema
3887                            let object_type =
3888                                self.analyze_object_schema(items_schema, dependencies)?;
3889
3890                            // Create an analyzed schema for the inline object
3891                            let inline_schema = AnalyzedSchema {
3892                                name: object_type_name.clone(),
3893                                original: serde_json::to_value(items_schema).unwrap_or(Value::Null),
3894                                schema_type: object_type,
3895                                dependencies: dependencies.clone(),
3896                                nullable: false,
3897                                description: items_schema.details().description.clone(),
3898                                default: None,
3899                            };
3900
3901                            // Add the inline object as a named schema
3902                            self.resolved_cache
3903                                .insert(object_type_name.clone(), inline_schema);
3904                            dependencies.insert(object_type_name.clone());
3905
3906                            // Return a reference to the named schema
3907                            SchemaType::Reference {
3908                                target: object_type_name,
3909                            }
3910                        }
3911                        OpenApiSchemaType::Array => {
3912                            // Array of arrays - recursively analyze
3913                            self.analyze_array_schema(
3914                                items_schema,
3915                                parent_schema_name,
3916                                dependencies,
3917                            )?
3918                        }
3919                        _ => SchemaType::Primitive {
3920                            rust_type: "serde_json::Value".to_string(),
3921                            serde_with: None,
3922                        },
3923                    }
3924                }
3925                Schema::OneOf { .. } | Schema::AnyOf { .. } => {
3926                    // Union types in arrays - analyze recursively
3927                    let analyzed = self.analyze_schema_value(items_schema, "ArrayItem")?;
3928
3929                    // If we got a discriminated union or union, we need to create a separate schema for it
3930                    match &analyzed.schema_type {
3931                        SchemaType::DiscriminatedUnion { .. } | SchemaType::Union { .. } => {
3932                            // Generate a unique name for the union schema based on the parent context
3933                            // Use the parent context directly to maintain consistent naming
3934                            let union_name = format!("{parent_schema_name}ItemUnion");
3935
3936                            // Create a new analyzed schema with the correct name
3937                            let mut union_schema = analyzed;
3938                            union_schema.name = union_name.clone();
3939
3940                            // Add the union as a separate schema
3941                            self.resolved_cache.insert(union_name.clone(), union_schema);
3942
3943                            // Add dependency
3944                            dependencies.insert(union_name.clone());
3945
3946                            // Return a reference to the union schema
3947                            SchemaType::Reference { target: union_name }
3948                        }
3949                        _ => analyzed.schema_type,
3950                    }
3951                }
3952                Schema::Untyped { .. } => {
3953                    // Try to infer the type
3954                    if let Some(inferred) = items_schema.inferred_type() {
3955                        match inferred {
3956                            OpenApiSchemaType::Object => {
3957                                // Inline object in array - create a named schema for it
3958                                let object_type_name = format!("{parent_schema_name}Item");
3959
3960                                // Analyze the object schema
3961                                let object_type =
3962                                    self.analyze_object_schema(items_schema, dependencies)?;
3963
3964                                // Create an analyzed schema for the inline object
3965                                let inline_schema = AnalyzedSchema {
3966                                    name: object_type_name.clone(),
3967                                    original: serde_json::to_value(items_schema)
3968                                        .unwrap_or(Value::Null),
3969                                    schema_type: object_type,
3970                                    dependencies: dependencies.clone(),
3971                                    nullable: false,
3972                                    description: items_schema.details().description.clone(),
3973                                    default: None,
3974                                };
3975
3976                                // Add the inline object as a named schema
3977                                self.resolved_cache
3978                                    .insert(object_type_name.clone(), inline_schema);
3979                                dependencies.insert(object_type_name.clone());
3980
3981                                // Return a reference to the named schema
3982                                SchemaType::Reference {
3983                                    target: object_type_name,
3984                                }
3985                            }
3986                            OpenApiSchemaType::String => {
3987                                // Typeless (OpenAPI 3.1) enum in array items —
3988                                // same hoisting as the typed-string arm.
3989                                match items_schema
3990                                    .details()
3991                                    .string_enum_values()
3992                                    .filter(|values| !values.is_empty())
3993                                {
3994                                    Some(values) => self.hoist_inline_string_enum(
3995                                        items_schema,
3996                                        values,
3997                                        format!("{parent_schema_name}Item"),
3998                                        dependencies,
3999                                    ),
4000                                    None => SchemaType::Primitive {
4001                                        rust_type: "String".to_string(),
4002                                        serde_with: None,
4003                                    },
4004                                }
4005                            }
4006                            OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
4007                                let details = items_schema.details();
4008                                let rust_type = self.get_number_rust_type(inferred, details);
4009                                SchemaType::Primitive {
4010                                    rust_type,
4011                                    serde_with: None,
4012                                }
4013                            }
4014                            OpenApiSchemaType::Boolean => SchemaType::Primitive {
4015                                rust_type: "bool".to_string(),
4016                                serde_with: None,
4017                            },
4018                            _ => SchemaType::Primitive {
4019                                rust_type: "serde_json::Value".to_string(),
4020                                serde_with: None,
4021                            },
4022                        }
4023                    } else {
4024                        SchemaType::Primitive {
4025                            rust_type: "serde_json::Value".to_string(),
4026                            serde_with: None,
4027                        }
4028                    }
4029                }
4030                _ => SchemaType::Primitive {
4031                    rust_type: "serde_json::Value".to_string(),
4032                    serde_with: None,
4033                },
4034            };
4035
4036            Ok(SchemaType::Array {
4037                item_type: Box::new(item_type),
4038            })
4039        } else {
4040            // No items specified, fall back to generic array
4041            Ok(SchemaType::Primitive {
4042                rust_type: "Vec<serde_json::Value>".to_string(),
4043                serde_with: None,
4044            })
4045        }
4046    }
4047
4048    fn get_number_rust_type(
4049        &self,
4050        schema_type: OpenApiSchemaType,
4051        details: &crate::openapi::SchemaDetails,
4052    ) -> String {
4053        // Q2.0: delegate to the TypeMapper chokepoint. The fallback for
4054        // non-numeric inputs is preserved for backwards compatibility
4055        // (callers in 2025-era code path `Integer | Number` here).
4056        let format = details.format.as_deref();
4057        match schema_type {
4058            OpenApiSchemaType::Integer => self.type_mapper.integer_format(format).rust_type,
4059            OpenApiSchemaType::Number => self.type_mapper.number_format(format).rust_type,
4060            _ => self.type_mapper.dynamic_json().rust_type,
4061        }
4062    }
4063
4064    fn analyze_anyof_union(
4065        &mut self,
4066        any_of_schemas: &[Schema],
4067        discriminator: Option<&Discriminator>,
4068        dependencies: &mut HashSet<String>,
4069        context_name: &str,
4070    ) -> Result<SchemaType> {
4071        // Drop {"type": "null"} variants. Nullability is surfaced as Option<T>
4072        // at the property level via is_nullable_pattern(); leaving the null
4073        // variant in here would produce a phantom `()` or `serde_json::Value`
4074        // type alias that the generator can't render.
4075        let filtered_owned: Vec<Schema>;
4076        let any_of_schemas: &[Schema] = if any_of_schemas
4077            .iter()
4078            .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
4079        {
4080            filtered_owned = any_of_schemas
4081                .iter()
4082                .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
4083                .cloned()
4084                .collect();
4085            if filtered_owned.is_empty() {
4086                return Ok(SchemaType::Primitive {
4087                    rust_type: "serde_json::Value".to_string(),
4088                    serde_with: None,
4089                });
4090            }
4091            if filtered_owned.len() == 1 {
4092                return self
4093                    .analyze_schema_value(&filtered_owned[0], context_name)
4094                    .map(|a| a.schema_type);
4095            }
4096            &filtered_owned
4097        } else {
4098            any_of_schemas
4099        };
4100
4101        // Pattern 2: Multiple complex types or mixed primitive/complex = flexible union
4102        let has_refs = any_of_schemas.iter().any(|s| s.is_reference());
4103        let has_objects = any_of_schemas.iter().any(|s| {
4104            matches!(s.schema_type(), Some(OpenApiSchemaType::Object))
4105                || s.inferred_type() == Some(OpenApiSchemaType::Object)
4106        });
4107        let has_arrays = any_of_schemas
4108            .iter()
4109            .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Array)));
4110
4111        // Handle mixed primitive and complex types (like string + array of objects)
4112        // Skip this pattern if all schemas are strings or const values (handle in pattern 3)
4113        let all_string_like = any_of_schemas.iter().all(|s| {
4114            matches!(s.schema_type(), Some(OpenApiSchemaType::String))
4115                || s.details().const_value.is_some()
4116        });
4117
4118        if (has_refs || has_objects || has_arrays || any_of_schemas.len() > 1) && !all_string_like {
4119            // Check if this is a discriminated union
4120            if let Some(disc) = discriminator {
4121                // This is a discriminated anyOf union, analyze it the same way as oneOf
4122                return self.analyze_oneof_union(
4123                    any_of_schemas,
4124                    Some(disc),
4125                    context_name,
4126                    dependencies,
4127                );
4128            }
4129
4130            // Auto-detect implicit discriminator from const fields across all variants
4131            if let Some(disc_field) = self.detect_discriminator_field(any_of_schemas) {
4132                return self.analyze_oneof_union(
4133                    any_of_schemas,
4134                    Some(&Discriminator {
4135                        property_name: disc_field,
4136                        mapping: None,
4137                        default_mapping: None,
4138                        extensions: crate::extensions::Extensions::default(),
4139                    }),
4140                    context_name,
4141                    dependencies,
4142                );
4143            }
4144
4145            // Create an untagged union for flexible matching
4146            let mut variants = Vec::new();
4147
4148            for schema in any_of_schemas {
4149                if let Some(ref_str) = schema.reference() {
4150                    if let Some(target) = self.extract_schema_name(ref_str) {
4151                        dependencies.insert(target.to_string());
4152                        variants.push(SchemaRef {
4153                            target: target.to_string(),
4154                            nullable: false,
4155                        });
4156                    }
4157                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object))
4158                    || schema.inferred_type() == Some(OpenApiSchemaType::Object)
4159                {
4160                    // Generate inline object type for anyOf union
4161                    let inline_index = variants.len();
4162                    let inline_type_name = self.generate_inline_type_name(schema, inline_index);
4163
4164                    // Store inline schema for later analysis and generation
4165                    self.add_inline_schema(&inline_type_name, schema, dependencies)?;
4166
4167                    variants.push(SchemaRef {
4168                        target: inline_type_name,
4169                        nullable: false,
4170                    });
4171                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Array)) {
4172                    // Handle array types in unions by creating a type alias
4173                    let array_type =
4174                        self.analyze_array_schema(schema, context_name, dependencies)?;
4175
4176                    // Create a unique name for this array type in the union
4177                    let array_type_name = if let Some(items_schema) = &schema.details().items {
4178                        if let Some(ref_str) = items_schema.reference() {
4179                            if let Some(item_type_name) = self.extract_schema_name(ref_str) {
4180                                dependencies.insert(item_type_name.to_string());
4181                                format!("{item_type_name}Array")
4182                            } else {
4183                                self.generate_context_aware_name(
4184                                    context_name,
4185                                    "Array",
4186                                    variants.len(),
4187                                    Some(schema),
4188                                )
4189                            }
4190                        } else {
4191                            self.generate_context_aware_name(
4192                                context_name,
4193                                "Array",
4194                                variants.len(),
4195                                Some(schema),
4196                            )
4197                        }
4198                    } else {
4199                        self.generate_context_aware_name(
4200                            context_name,
4201                            "Array",
4202                            variants.len(),
4203                            Some(schema),
4204                        )
4205                    };
4206
4207                    // Store the array as a type alias
4208                    self.resolved_cache.insert(
4209                        array_type_name.clone(),
4210                        AnalyzedSchema {
4211                            name: array_type_name.clone(),
4212                            original: serde_json::to_value(schema).unwrap_or(Value::Null),
4213                            schema_type: array_type,
4214                            dependencies: HashSet::new(),
4215                            nullable: false,
4216                            description: Some("Array variant in union".to_string()),
4217                            default: None,
4218                        },
4219                    );
4220
4221                    // Add array type as a dependency
4222                    dependencies.insert(array_type_name.clone());
4223
4224                    variants.push(SchemaRef {
4225                        target: array_type_name,
4226                        nullable: false,
4227                    });
4228                } else if let Some(schema_type) = schema.schema_type() {
4229                    // Q2.7: when `primitive_unions` is on (default),
4230                    // emit the Rust type directly as the variant
4231                    // target — matches `analyze_untagged_oneof_union`
4232                    // and produces a clean
4233                    //   #[serde(untagged)] pub enum Foo { String(String), Integer(i64) }
4234                    // Pre-Q2.7 / opt-out emits a type alias per
4235                    // primitive (`pub type FooString = String`) and
4236                    // references the alias in the variant — works
4237                    // but adds noise.
4238                    let primitive_unions = self
4239                        .type_mapper
4240                        .config_shape_primitive_unions()
4241                        .unwrap_or(true);
4242
4243                    if primitive_unions {
4244                        let mapped = self.type_mapper.map(schema_type.clone(), schema.details());
4245                        variants.push(SchemaRef {
4246                            target: mapped.rust_type,
4247                            nullable: false,
4248                        });
4249                    } else {
4250                        let inline_index = variants.len();
4251                        let inline_type_name = match schema_type {
4252                            OpenApiSchemaType::String => {
4253                                if inline_index == 0 {
4254                                    format!("{context_name}String")
4255                                } else {
4256                                    format!("{context_name}StringVariant{inline_index}")
4257                                }
4258                            }
4259                            OpenApiSchemaType::Number => {
4260                                if inline_index == 0 {
4261                                    format!("{context_name}Number")
4262                                } else {
4263                                    format!("{context_name}NumberVariant{inline_index}")
4264                                }
4265                            }
4266                            OpenApiSchemaType::Integer => {
4267                                if inline_index == 0 {
4268                                    format!("{context_name}Integer")
4269                                } else {
4270                                    format!("{context_name}IntegerVariant{inline_index}")
4271                                }
4272                            }
4273                            OpenApiSchemaType::Boolean => {
4274                                if inline_index == 0 {
4275                                    format!("{context_name}Boolean")
4276                                } else {
4277                                    format!("{context_name}BooleanVariant{inline_index}")
4278                                }
4279                            }
4280                            _ => format!("{context_name}Variant{inline_index}"),
4281                        };
4282
4283                        let rust_type =
4284                            self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
4285
4286                        self.resolved_cache.insert(
4287                            inline_type_name.clone(),
4288                            AnalyzedSchema {
4289                                name: inline_type_name.clone(),
4290                                original: serde_json::to_value(schema).unwrap_or(Value::Null),
4291                                schema_type: SchemaType::Primitive {
4292                                    rust_type,
4293                                    serde_with: None,
4294                                },
4295                                dependencies: HashSet::new(),
4296                                nullable: false,
4297                                description: schema.details().description.clone(),
4298                                default: None,
4299                            },
4300                        );
4301
4302                        dependencies.insert(inline_type_name.clone());
4303
4304                        variants.push(SchemaRef {
4305                            target: inline_type_name,
4306                            nullable: false,
4307                        });
4308                    }
4309                }
4310            }
4311
4312            if !variants.is_empty() {
4313                return Ok(SchemaType::Union { variants });
4314            }
4315        }
4316
4317        // Pattern 3: String enum pattern (mix of "type": "string" and const values)
4318        let all_strings = any_of_schemas.iter().all(|schema| {
4319            matches!(schema.schema_type(), Some(OpenApiSchemaType::String))
4320                || schema.details().const_value.is_some()
4321        });
4322
4323        if all_strings {
4324            // Collect all constant values as enum variants
4325            let mut enum_values = Vec::new();
4326            let mut has_open_string = false;
4327
4328            for schema in any_of_schemas {
4329                if let Some(const_val) = &schema.details().const_value {
4330                    if let Some(const_str) = const_val.as_str() {
4331                        enum_values.push(const_str.to_string());
4332                    }
4333                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::String)) {
4334                    has_open_string = true;
4335                }
4336            }
4337
4338            if !enum_values.is_empty() {
4339                if has_open_string {
4340                    // Has both constants and open string - create an extensible enum
4341                    // This generates an enum with known variants plus a Custom(String) variant
4342                    return Ok(SchemaType::ExtensibleEnum {
4343                        known_values: enum_values,
4344                    });
4345                } else {
4346                    // All constants - create string enum
4347                    return Ok(SchemaType::StringEnum {
4348                        values: enum_values,
4349                    });
4350                }
4351            }
4352        }
4353
4354        // Pattern 4: Mixed primitives = fall back to serde_json::Value
4355        Ok(SchemaType::Primitive {
4356            rust_type: "serde_json::Value".to_string(),
4357            serde_with: None,
4358        })
4359    }
4360
4361    /// Find the schema with $recursiveAnchor: true for resolving $recursiveRef: "#"
4362    fn find_recursive_anchor_schema(&self) -> Option<String> {
4363        // Search through all schemas to find one with $recursiveAnchor: true
4364        for (schema_name, schema) in &self.schemas {
4365            let details = schema.details();
4366            if details.recursive_anchor == Some(true) {
4367                return Some(schema_name.clone());
4368            }
4369        }
4370
4371        // If no schema has $recursiveAnchor: true, this might be an older spec
4372        // In that case, $recursiveRef: "#" typically refers to the root schema
4373        // For now, return None to indicate we couldn't resolve it
4374        None
4375    }
4376
4377    /// Detect if a schema should use serde_json::Value for dynamic JSON
4378    /// Based on structural patterns identified in real-world APIs
4379    fn should_use_dynamic_json(&self, schema: &Schema) -> bool {
4380        // Pattern 1: anyOf with [object, null] where object has no properties
4381        if let Schema::AnyOf { any_of, .. } = schema {
4382            if any_of.len() == 2 {
4383                let has_null = any_of
4384                    .iter()
4385                    .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)));
4386                let has_empty_object = any_of.iter().any(|s| self.is_dynamic_object_pattern(s));
4387
4388                if has_null && has_empty_object {
4389                    return true;
4390                }
4391            }
4392        }
4393
4394        // Pattern 2: Direct empty object pattern
4395        self.is_dynamic_object_pattern(schema)
4396    }
4397
4398    /// Check if a schema represents a dynamic object pattern
4399    fn is_dynamic_object_pattern(&self, schema: &Schema) -> bool {
4400        // Must be object type or untyped with object inference
4401        let is_object = match schema.schema_type() {
4402            Some(OpenApiSchemaType::Object) => true,
4403            None => schema.inferred_type() == Some(OpenApiSchemaType::Object),
4404            _ => false,
4405        };
4406
4407        if !is_object {
4408            return false;
4409        }
4410
4411        let details = schema.details();
4412
4413        // If it has explicit additionalProperties, it should remain as a typed object
4414        // that will be generated as BTreeMap<String, serde_json::Value> or similar
4415        if self.has_explicit_additional_properties(schema) {
4416            return false;
4417        }
4418
4419        // Pattern 1: Object with no properties at all (and no additionalProperties)
4420        let no_properties = details
4421            .properties
4422            .as_ref()
4423            .map(|props| props.is_empty())
4424            .unwrap_or(true);
4425
4426        if no_properties {
4427            // Check for constraints that would make this a structured type.
4428            // After J5–J8, these are typed fields rather than `extra` lookups.
4429            let has_structural_constraints = details
4430                .required
4431                .as_ref()
4432                .map(|req| req.iter().any(|r| r != "type"))
4433                .unwrap_or(false)
4434                || details.pattern_properties.is_some()
4435                || details.property_names.is_some()
4436                || details.min_properties.is_some()
4437                || details.max_properties.is_some()
4438                || details.dependent_required.is_some()
4439                || details.dependent_schemas.is_some()
4440                || details.if_schema.is_some()
4441                || details.then_schema.is_some()
4442                || details.else_schema.is_some();
4443
4444            return !has_structural_constraints;
4445        }
4446
4447        false
4448    }
4449
4450    /// Check if this is an object that explicitly allows arbitrary additional properties
4451    fn has_explicit_additional_properties(&self, schema: &Schema) -> bool {
4452        let details = schema.details();
4453
4454        // Check if additionalProperties is explicitly set to true or a schema
4455        matches!(
4456            &details.additional_properties,
4457            Some(crate::openapi::AdditionalProperties::Boolean(true))
4458                | Some(crate::openapi::AdditionalProperties::Schema(_))
4459        )
4460    }
4461
4462    /// Analyze OpenAPI operations to extract request/response schemas
4463    fn analyze_operations(&mut self, analysis: &mut SchemaAnalysis) -> Result<()> {
4464        let spec: crate::openapi::OpenApiSpec = serde_json::from_value(self.openapi_spec.clone())
4465            .map_err(GeneratorError::ParseError)?;
4466        // Operation IDs are emitted into one Rust module, so collision
4467        // detection spans paths and webhooks. Index their canonical Rust type
4468        // names once instead of re-canonicalizing every previously analyzed
4469        // operation for every new endpoint.
4470        let mut canonical_operation_ids = HashSet::new();
4471
4472        if let Some(paths) = &spec.paths {
4473            for (path, path_item) in paths {
4474                // H11: Path Item may be a $ref to components/pathItems. Resolve here.
4475                let resolved = self.resolve_path_item(path_item, &spec)?;
4476                let pi: &crate::openapi::PathItem = resolved.as_ref().unwrap_or(path_item);
4477                self.ingest_path_item_operations(path, pi, analysis, &mut canonical_operation_ids)?;
4478            }
4479        }
4480        // T4: walk webhooks the same way as paths. Per OAS 3.1+, webhooks are
4481        // server→consumer callbacks: their request bodies describe payloads
4482        // the *server* sends *to* the consumer. We currently emit them as
4483        // ordinary operations so their request/response types land in the
4484        // generated client; a future bead may add a typed Webhook enum and
4485        // dispatcher.
4486        if let Some(webhooks) = &spec.webhooks {
4487            for (name, path_item) in webhooks {
4488                let synthetic_path = format!("/__webhook__/{name}");
4489                self.ingest_path_item_operations(
4490                    &synthetic_path,
4491                    path_item,
4492                    analysis,
4493                    &mut canonical_operation_ids,
4494                )?;
4495            }
4496        }
4497        Ok(())
4498    }
4499
4500    /// H11: Resolve a Path Item's `$ref` (3.1+ allows them) against
4501    /// `components/pathItems`. Returns Some(resolved) when a ref was followed,
4502    /// or None when the input is already inline.
4503    fn resolve_path_item(
4504        &self,
4505        path_item: &crate::openapi::PathItem,
4506        spec: &crate::openapi::OpenApiSpec,
4507    ) -> Result<Option<crate::openapi::PathItem>> {
4508        let Some(reference) = &path_item.reference else {
4509            return Ok(None);
4510        };
4511        let target_name = reference
4512            .strip_prefix("#/components/pathItems/")
4513            .ok_or_else(|| {
4514                GeneratorError::UnresolvedReference(format!(
4515                    "Path Item $ref must point at #/components/pathItems/{{name}}, got {reference}"
4516                ))
4517            })?;
4518        let pi = spec
4519            .components
4520            .as_ref()
4521            .and_then(|c| c.path_items.as_ref())
4522            .and_then(|map| map.get(target_name))
4523            .ok_or_else(|| {
4524                GeneratorError::UnresolvedReference(format!(
4525                    "Path Item ref {reference} not found in components/pathItems"
4526                ))
4527            })?;
4528        Ok(Some(pi.clone()))
4529    }
4530
4531    fn ingest_path_item_operations(
4532        &mut self,
4533        path: &str,
4534        path_item: &crate::openapi::PathItem,
4535        analysis: &mut SchemaAnalysis,
4536        canonical_operation_ids: &mut HashSet<String>,
4537    ) -> Result<()> {
4538        for (method, operation) in path_item.operations() {
4539            // Generate operation ID if missing.
4540            let raw_operation_id = operation
4541                .operation_id
4542                .clone()
4543                .unwrap_or_else(|| Self::generate_operation_id(method, path));
4544
4545            // T6: detect operationId collisions. Per the OAS spec these MUST
4546            // be unique, but real-world specs (arcade, cal-com, telnyx,
4547            // val-town, …) frequently aren't. Auto-disambiguate by suffixing
4548            // with the method, then a counter, and warn.
4549            //
4550            // The collision key is the PascalCased form so that case-only
4551            // differences (telnyx has `getMdrUsageReports` AND
4552            // `GetMdrUsageReports`) collide too — otherwise codegen would
4553            // produce two `GetMdrUsageReportsApiError` enums in the same
4554            // module.
4555            let operation_id = if canonical_operation_ids
4556                .contains(&Self::canonical_operation_id(&raw_operation_id))
4557            {
4558                let method_lower = method.to_lowercase();
4559                let mut candidate = format!("{}_{}", raw_operation_id, method_lower);
4560                let mut suffix = 2;
4561                while canonical_operation_ids.contains(&Self::canonical_operation_id(&candidate)) {
4562                    candidate = format!("{}_{}_{}", raw_operation_id, method_lower, suffix);
4563                    suffix += 1;
4564                }
4565                eprintln!(
4566                    "⚠️  duplicate operationId `{}` at `{} {}` — disambiguated to `{}`",
4567                    raw_operation_id, method, path, candidate
4568                );
4569                candidate
4570            } else {
4571                raw_operation_id.clone()
4572            };
4573
4574            let (op_info, responses) = self.analyze_single_operation(
4575                &operation_id,
4576                method,
4577                path,
4578                operation,
4579                path_item.parameters.as_ref(),
4580                analysis,
4581            )?;
4582            analysis
4583                .operation_id_aliases
4584                .entry(raw_operation_id)
4585                .or_default()
4586                .push(operation_id.clone());
4587            canonical_operation_ids.insert(Self::canonical_operation_id(&operation_id));
4588            analysis
4589                .operation_responses
4590                .insert(operation_id.clone(), responses);
4591            analysis.operations.insert(operation_id, op_info);
4592        }
4593        Ok(())
4594    }
4595
4596    fn canonical_operation_id(operation_id: &str) -> String {
4597        use heck::ToPascalCase;
4598        operation_id.replace('.', "_").to_pascal_case()
4599    }
4600
4601    /// Generate an operation ID from method and path when not provided
4602    /// Converts paths like "/v0/servers/{serverId}" + "get" to "getV0ServersServerId"
4603    fn generate_operation_id(method: &str, path: &str) -> String {
4604        // Start with the HTTP method in lowercase
4605        let mut operation_id = method.to_lowercase();
4606
4607        // Process the path: remove leading slash, split by /, convert to camelCase
4608        let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
4609
4610        for part in path_parts {
4611            if part.is_empty() {
4612                continue;
4613            }
4614
4615            // Handle path parameters: {serverId} -> ServerId
4616            let cleaned_part = if part.starts_with('{') && part.ends_with('}') {
4617                &part[1..part.len() - 1]
4618            } else {
4619                part
4620            };
4621
4622            // Convert to PascalCase and append
4623            let pascal_case_part = cleaned_part
4624                .split(&['-', '_'][..])
4625                .map(|s| {
4626                    let mut chars = s.chars();
4627                    match chars.next() {
4628                        None => String::new(),
4629                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4630                    }
4631                })
4632                .collect::<String>();
4633
4634            operation_id.push_str(&pascal_case_part);
4635        }
4636
4637        operation_id
4638    }
4639
4640    /// Analyze a single OpenAPI operation
4641    fn analyze_single_operation(
4642        &mut self,
4643        operation_id: &str,
4644        method: &str,
4645        path: &str,
4646        operation: &crate::openapi::Operation,
4647        path_item_parameters: Option<&Vec<crate::openapi::Parameter>>,
4648        _analysis: &mut SchemaAnalysis,
4649    ) -> Result<(OperationInfo, BTreeMap<String, OperationResponse>)> {
4650        let raw_path_item = self
4651            .openapi_spec
4652            .get("paths")
4653            .and_then(|paths| paths.get(path))
4654            .cloned();
4655        let raw_operation = raw_path_item
4656            .as_ref()
4657            .and_then(|path_item| path_item.get(method.to_ascii_lowercase()))
4658            .cloned();
4659        let request_body = operation
4660            .request_body
4661            .as_ref()
4662            .map(|request_body| self.resolve_request_body(request_body))
4663            .transpose()?;
4664        let mut op_info = OperationInfo {
4665            operation_id: operation_id.to_string(),
4666            method: method.to_uppercase(),
4667            path: normalize_operation_path(path),
4668            summary: operation.summary.clone(),
4669            description: operation.description.clone(),
4670            request_body: None,
4671            // Per OAS 3.x §"Request Body Object", `required` defaults to false.
4672            request_body_required: request_body
4673                .as_ref()
4674                .and_then(|rb| rb.required)
4675                .unwrap_or(false),
4676            response_schemas: BTreeMap::new(),
4677            parameters: Vec::new(),
4678            supports_streaming: false, // Will be determined by StreamingConfig, not spec
4679            stream_parameter: None,    // Will be determined by StreamingConfig, not spec
4680            tags: operation.tags.clone().unwrap_or_default(),
4681        };
4682        let mut operation_responses = BTreeMap::new();
4683
4684        // Extract request body schema with content-type awareness
4685        if let Some(request_body) = &request_body {
4686            use crate::openapi::{
4687                is_binary_media_type, is_form_urlencoded_media_type, is_json_media_type,
4688                media_type_essence,
4689            };
4690            if let Some((content_type, maybe_schema)) = request_body.best_content() {
4691                op_info.request_body = if is_json_media_type(content_type) {
4692                    match maybe_schema {
4693                        Some(s) => {
4694                            let validation_schema = self
4695                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
4696                                .unwrap_or(
4697                                    serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
4698                                );
4699                            Some(
4700                                self.resolve_or_inline_schema(s, operation_id, "Request")
4701                                    .map(|name| RequestBodyContent::Json {
4702                                        schema_name: name,
4703                                        media_type: content_type.to_string(),
4704                                        validation_schema,
4705                                    })?,
4706                            )
4707                        }
4708                        None => Some(RequestBodyContent::SchemaLess {
4709                            media_type: content_type.to_string(),
4710                        }),
4711                    }
4712                } else if is_form_urlencoded_media_type(content_type) {
4713                    match maybe_schema {
4714                        Some(s) => {
4715                            let validation_schema = self
4716                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
4717                                .unwrap_or(
4718                                    serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
4719                                );
4720                            Some(
4721                                self.resolve_or_inline_schema(s, operation_id, "Request")
4722                                    .map(|name| RequestBodyContent::FormUrlEncoded {
4723                                        schema_name: name,
4724                                        media_type: content_type.to_string(),
4725                                        validation_schema,
4726                                    })?,
4727                            )
4728                        }
4729                        None => Some(RequestBodyContent::SchemaLess {
4730                            media_type: content_type.to_string(),
4731                        }),
4732                    }
4733                } else if media_type_essence(content_type)
4734                    .eq_ignore_ascii_case("multipart/form-data")
4735                {
4736                    match maybe_schema {
4737                        Some(schema) => {
4738                            let validation_schema = self
4739                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
4740                                .unwrap_or(
4741                                    serde_json::to_value(schema)
4742                                        .map_err(GeneratorError::ParseError)?,
4743                                );
4744                            Some(
4745                                self.resolve_or_inline_schema(schema, operation_id, "Request")
4746                                    .map(|schema_name| RequestBodyContent::Multipart {
4747                                        schema_name,
4748                                        media_type: content_type.to_string(),
4749                                        validation_schema,
4750                                    })?,
4751                            )
4752                        }
4753                        None => Some(RequestBodyContent::SchemaLess {
4754                            media_type: content_type.to_string(),
4755                        }),
4756                    }
4757                } else if is_binary_media_type(content_type, maybe_schema) {
4758                    if media_type_essence(content_type)
4759                        .eq_ignore_ascii_case("application/octet-stream")
4760                    {
4761                        Some(RequestBodyContent::OctetStream {
4762                            media_type: content_type.to_string(),
4763                        })
4764                    } else {
4765                        Some(RequestBodyContent::Binary {
4766                            media_type: content_type.to_string(),
4767                        })
4768                    }
4769                } else if crate::openapi::is_text_media_type(content_type) {
4770                    // Any character-data media type (text/plain, text/xml,
4771                    // application/xml, +xml suffixed) is buffered and handed
4772                    // to the handler as a lossless UTF-8 String; the server
4773                    // never parses the payload.
4774                    Some(RequestBodyContent::TextPlain {
4775                        media_type: content_type.to_string(),
4776                    })
4777                } else {
4778                    None
4779                };
4780            }
4781            if op_info.request_body.is_none() {
4782                let mut media_types = request_body
4783                    .content
4784                    .as_ref()
4785                    .map(|content| content.keys().cloned().collect::<Vec<_>>())
4786                    .unwrap_or_default();
4787                media_types.sort();
4788                if !media_types.is_empty() {
4789                    op_info.request_body = Some(RequestBodyContent::Unsupported { media_types });
4790                }
4791            }
4792        }
4793
4794        // Extract response schemas
4795        if let Some(responses) = &operation.responses {
4796            for (status_code, response) in responses {
4797                let response = self.resolve_response(response)?;
4798                // T15: SSE auto-detection. If any response declares
4799                // `text/event-stream`, mark the operation as streaming. The
4800                // user can still override via config; here we lift the spec
4801                // signal so a `stream: true` parameter and an event-stream
4802                // content type produce a streaming variant by default.
4803                let supports_streaming = response.content.as_ref().is_some_and(|content| {
4804                    content
4805                        .keys()
4806                        .any(|ct| crate::openapi::is_event_stream_media_type(ct))
4807                });
4808                if supports_streaming {
4809                    op_info.supports_streaming = true;
4810                }
4811
4812                let mut response_info = OperationResponse {
4813                    supports_streaming,
4814                    has_content: response
4815                        .content
4816                        .as_ref()
4817                        .is_some_and(|content| !content.is_empty()),
4818                    ..Default::default()
4819                };
4820                if let Some((media_type, schema)) = response.json_content() {
4821                    if let Some(schema_ref) = schema.reference() {
4822                        // Named schema reference
4823                        if let Some(schema_name) = self.extract_schema_name(schema_ref) {
4824                            op_info
4825                                .response_schemas
4826                                .insert(status_code.clone(), schema_name.to_string());
4827                            response_info.schema_name = Some(schema_name.to_string());
4828                            response_info.media_type = Some(media_type.to_string());
4829                            response_info.body = Some(OperationResponseBody::Json {
4830                                schema_name: schema_name.to_string(),
4831                                media_type: media_type.to_string(),
4832                            });
4833                        }
4834                    } else {
4835                        // Inline schema - generate a synthetic type name and analyze it
4836                        let synthetic_name =
4837                            self.generate_inline_response_type_name(operation_id, status_code);
4838
4839                        // Use the existing inline schema infrastructure
4840                        let mut deps = HashSet::new();
4841                        self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
4842
4843                        op_info
4844                            .response_schemas
4845                            .insert(status_code.clone(), synthetic_name.clone());
4846                        response_info.body = Some(OperationResponseBody::Json {
4847                            schema_name: synthetic_name.clone(),
4848                            media_type: media_type.to_string(),
4849                        });
4850                        response_info.schema_name = Some(synthetic_name);
4851                        response_info.media_type = Some(media_type.to_string());
4852                    }
4853                }
4854                if response_info.body.is_none()
4855                    && let Some(content) = response.content.as_ref()
4856                {
4857                    let selected = content
4858                        .iter()
4859                        .find(|(media_type, media)| {
4860                            matches!(
4861                                crate::openapi::classify_response_media_type(
4862                                    media_type,
4863                                    media.schema.as_ref()
4864                                ),
4865                                crate::openapi::ResponseMediaKind::Text
4866                            )
4867                        })
4868                        .or_else(|| {
4869                            content.iter().find(|(media_type, media)| {
4870                                matches!(
4871                                    crate::openapi::classify_response_media_type(
4872                                        media_type,
4873                                        media.schema.as_ref()
4874                                    ),
4875                                    crate::openapi::ResponseMediaKind::Binary
4876                                ) && !crate::openapi::is_wildcard_media_type(media_type)
4877                            })
4878                        })
4879                        .or_else(|| {
4880                            content.iter().find(|(media_type, media)| {
4881                                matches!(
4882                                    crate::openapi::classify_response_media_type(
4883                                        media_type,
4884                                        media.schema.as_ref()
4885                                    ),
4886                                    crate::openapi::ResponseMediaKind::Binary
4887                                )
4888                            })
4889                        });
4890                    if let Some((media_type, media)) = selected {
4891                        response_info.body = match crate::openapi::classify_response_media_type(
4892                            media_type,
4893                            media.schema.as_ref(),
4894                        ) {
4895                            crate::openapi::ResponseMediaKind::Text => {
4896                                Some(OperationResponseBody::Text {
4897                                    media_type: media_type.clone(),
4898                                })
4899                            }
4900                            crate::openapi::ResponseMediaKind::Binary => {
4901                                Some(OperationResponseBody::Binary {
4902                                    media_type: media_type.clone(),
4903                                    wildcard: crate::openapi::is_wildcard_media_type(media_type),
4904                                })
4905                            }
4906                            _ => None,
4907                        };
4908                    }
4909                }
4910                response_info.unsupported_media_types = response
4911                    .content
4912                    .as_ref()
4913                    .into_iter()
4914                    .flat_map(|content| content.iter())
4915                    .filter(|(media_type, content)| {
4916                        match crate::openapi::classify_response_media_type(
4917                            media_type,
4918                            content.schema.as_ref(),
4919                        ) {
4920                            crate::openapi::ResponseMediaKind::Json => content.schema.is_none(),
4921                            crate::openapi::ResponseMediaKind::Unsupported => true,
4922                            crate::openapi::ResponseMediaKind::EventStream
4923                            | crate::openapi::ResponseMediaKind::Text
4924                            | crate::openapi::ResponseMediaKind::Binary => false,
4925                        }
4926                    })
4927                    .map(|(media_type, _)| media_type.clone())
4928                    .collect();
4929                operation_responses.insert(status_code.clone(), response_info);
4930            }
4931        }
4932
4933        // T15: detect a `stream` boolean parameter on the operation; pair it
4934        // with the SSE response signal above to populate stream_parameter.
4935        if op_info.supports_streaming
4936            && let Some(parameters) = &operation.parameters
4937        {
4938            for param in parameters {
4939                if let Some(name) = param.name.as_deref() {
4940                    if name.eq_ignore_ascii_case("stream") {
4941                        op_info.stream_parameter = Some(name.to_string());
4942                        break;
4943                    }
4944                }
4945            }
4946        }
4947
4948        // Extract parameters (operation-level first, then merge path-item-level)
4949        if let Some(parameters) = &operation.parameters {
4950            for (index, param) in parameters.iter().enumerate() {
4951                // into_owned: analyze_parameter needs `&mut self` (it may
4952                // register an inline object schema for form-exploded query
4953                // params), which can't coexist with the Cow's `&self` borrow.
4954                let resolved = self.resolve_parameter(param).into_owned();
4955                let validation_schema = raw_operation
4956                    .as_ref()
4957                    .and_then(|operation| operation.get("parameters"))
4958                    .and_then(Value::as_array)
4959                    .and_then(|parameters| parameters.get(index))
4960                    .and_then(|parameter| self.raw_parameter_schema(parameter));
4961                if let Some(param_info) =
4962                    self.analyze_parameter(&resolved, operation_id, validation_schema)?
4963                {
4964                    op_info.parameters.push(param_info);
4965                }
4966            }
4967        }
4968
4969        // Merge path-item-level parameters (operation params take precedence per OpenAPI spec)
4970        if let Some(path_params) = path_item_parameters {
4971            let existing_keys: std::collections::HashSet<(String, String)> = op_info
4972                .parameters
4973                .iter()
4974                .map(|p| (p.name.clone(), p.location.clone()))
4975                .collect();
4976            for (index, param) in path_params.iter().enumerate() {
4977                let resolved = self.resolve_parameter(param).into_owned();
4978                let validation_schema = raw_path_item
4979                    .as_ref()
4980                    .and_then(|path_item| path_item.get("parameters"))
4981                    .and_then(Value::as_array)
4982                    .and_then(|parameters| parameters.get(index))
4983                    .and_then(|parameter| self.raw_parameter_schema(parameter));
4984                if let Some(param_info) =
4985                    self.analyze_parameter(&resolved, operation_id, validation_schema)?
4986                {
4987                    if !existing_keys
4988                        .contains(&(param_info.name.clone(), param_info.location.clone()))
4989                    {
4990                        op_info.parameters.push(param_info);
4991                    }
4992                }
4993            }
4994        }
4995
4996        // Synthesize path parameters that are referenced via `{var}` in the
4997        // path template but not declared as parameters in the spec.
4998        // langsmith/knocklabs/cloudflare hit this — `/repos/{owner}/{repo}/...`
4999        // declares `repo` but not `owner`. Without this, codegen emits
5000        // `format!("/repos/{owner}/...", repo)` and `owner` is undefined
5001        // (E0425). We synthesize each missing variable as a required
5002        // `String` path parameter.
5003        let mut declared_path_names: std::collections::HashSet<String> = op_info
5004            .parameters
5005            .iter()
5006            .filter(|p| p.location == "path")
5007            .map(|p| p.name.clone())
5008            .collect();
5009        let bytes = path.as_bytes().iter();
5010        let mut current = String::new();
5011        let mut in_brace = false;
5012        let mut synthesized: Vec<String> = Vec::new();
5013        for b in bytes {
5014            match *b {
5015                b'{' => {
5016                    in_brace = true;
5017                    current.clear();
5018                }
5019                b'}' if in_brace => {
5020                    in_brace = false;
5021                    if !current.is_empty() && !declared_path_names.contains(&current) {
5022                        synthesized.push(current.clone());
5023                        declared_path_names.insert(current.clone());
5024                    }
5025                }
5026                _ if in_brace => current.push(*b as char),
5027                _ => {}
5028            }
5029        }
5030        for name in synthesized {
5031            eprintln!(
5032                "⚠️  path `{}` references `{{{}}}` but the spec doesn't declare it as a parameter — synthesizing as required String",
5033                path, name
5034            );
5035            op_info.parameters.push(ParameterInfo {
5036                name,
5037                location: "path".to_string(),
5038                required: true,
5039                schema_ref: None,
5040                rust_type: "String".to_string(),
5041                description: None,
5042                enum_values: None,
5043                enum_varnames: None,
5044                rust_ident: None,
5045                query_serialization: None,
5046                validation_schema: None,
5047            });
5048        }
5049
5050        // Disambiguate Rust idents across the operation. Real-world specs
5051        // sometimes use both `kebab-case` and `snake_case` for closely-related
5052        // filter parameters (vercel: `exclude_ids` + `exclude-ids`), or
5053        // operator-suffixed forms (twilio: `StartTime`, `StartTime<`,
5054        // `StartTime>`). Without disambiguation those parameters share a
5055        // single binding and the generated body fails E0382 (use of moved
5056        // value) or E0415 (binding declared twice).
5057        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
5058        for p in op_info.parameters.iter_mut() {
5059            let raw = base_param_ident(&p.name);
5060            let mut chosen = raw.clone();
5061            let mut suffix = 2;
5062            while !used.insert(chosen.clone()) {
5063                chosen = format!("{raw}_{suffix}");
5064                suffix += 1;
5065            }
5066            p.rust_ident = Some(chosen);
5067        }
5068
5069        Ok((op_info, operation_responses))
5070    }
5071
5072    /// Resolve a local reusable Request Body Object through its JSON Pointer.
5073    fn resolve_request_body(
5074        &self,
5075        request_body: &crate::openapi::RequestBody,
5076    ) -> Result<crate::openapi::RequestBody> {
5077        let mut current = request_body.clone();
5078        let mut visited = HashSet::new();
5079        while let Some(reference) = current.reference.clone() {
5080            if !visited.insert(reference.clone()) {
5081                return Err(GeneratorError::CircularDependency(format!(
5082                    "request body reference {reference}"
5083                )));
5084            }
5085
5086            let pointer = reference.strip_prefix('#').ok_or_else(|| {
5087                GeneratorError::UnresolvedReference(format!(
5088                    "external request body reference `{reference}` is not supported"
5089                ))
5090            })?;
5091            if !pointer.is_empty() && !pointer.starts_with('/') {
5092                return Err(GeneratorError::UnresolvedReference(format!(
5093                    "request body reference `{reference}` is not a local JSON Pointer"
5094                )));
5095            }
5096            let value = self.openapi_spec.pointer(pointer).ok_or_else(|| {
5097                GeneratorError::UnresolvedReference(format!(
5098                    "request body reference `{reference}` does not exist"
5099                ))
5100            })?;
5101            let object = value.as_object().ok_or_else(|| {
5102                GeneratorError::InvalidSchema(format!(
5103                    "request body reference `{reference}` must target an object"
5104                ))
5105            })?;
5106            if !["$ref", "description", "required", "content"]
5107                .iter()
5108                .any(|field| object.contains_key(*field))
5109            {
5110                return Err(GeneratorError::InvalidSchema(format!(
5111                    "request body reference `{reference}` does not target a structurally compatible OpenAPI Request Body Object"
5112                )));
5113            }
5114            current = serde_json::from_value(value.clone()).map_err(|error| {
5115                GeneratorError::InvalidSchema(format!(
5116                    "request body reference `{reference}` is not a valid OpenAPI Request Body Object: {error}"
5117                ))
5118            })?;
5119        }
5120        Ok(current)
5121    }
5122
5123    /// Resolve a local reusable Response Object through its JSON Pointer.
5124    ///
5125    /// Real-world documents occasionally store a structurally valid Response
5126    /// Object under the wrong Components map. Resolving the pointer itself
5127    /// preserves compatibility with those documents while still validating
5128    /// that the target can be interpreted as a Response Object.
5129    fn resolve_response(
5130        &self,
5131        response: &crate::openapi::Response,
5132    ) -> Result<crate::openapi::Response> {
5133        let mut current = response.clone();
5134        let mut visited = HashSet::new();
5135        while let Some(reference) = current.reference.clone() {
5136            if !visited.insert(reference.clone()) {
5137                return Err(GeneratorError::CircularDependency(format!(
5138                    "response reference {reference}"
5139                )));
5140            }
5141
5142            let pointer = reference.strip_prefix('#').ok_or_else(|| {
5143                GeneratorError::UnresolvedReference(format!(
5144                    "external response reference `{reference}` is not supported"
5145                ))
5146            })?;
5147            if !pointer.is_empty() && !pointer.starts_with('/') {
5148                return Err(GeneratorError::UnresolvedReference(format!(
5149                    "response reference `{reference}` is not a local JSON Pointer"
5150                )));
5151            }
5152            let value = self.openapi_spec.pointer(pointer).ok_or_else(|| {
5153                GeneratorError::UnresolvedReference(format!(
5154                    "response reference `{reference}` does not exist"
5155                ))
5156            })?;
5157            let object = value.as_object().ok_or_else(|| {
5158                GeneratorError::InvalidSchema(format!(
5159                    "response reference `{reference}` must target an object"
5160                ))
5161            })?;
5162            if !["$ref", "description", "headers", "content", "links"]
5163                .iter()
5164                .any(|field| object.contains_key(*field))
5165            {
5166                return Err(GeneratorError::InvalidSchema(format!(
5167                    "response reference `{reference}` does not target a structurally compatible OpenAPI Response Object"
5168                )));
5169            }
5170            current = serde_json::from_value(value.clone()).map_err(|error| {
5171                GeneratorError::InvalidSchema(format!(
5172                    "response reference `{reference}` is not a valid OpenAPI Response Object: {error}"
5173                ))
5174            })?;
5175        }
5176        Ok(current)
5177    }
5178
5179    /// Generate a type name for an inline response schema.
5180    ///
5181    /// 200 (the canonical success status) keeps the unsuffixed `{Op}Response`
5182    /// name so simple specs and existing snapshots are unchanged. Every other
5183    /// status code is disambiguated by suffix so that multi-response operations
5184    /// (e.g. 200 + 400) don't collide in the schema registry — see issue #8.
5185    fn generate_inline_response_type_name(&self, operation_id: &str, status_code: &str) -> String {
5186        use heck::ToPascalCase;
5187        let base_name = operation_id.replace('.', "_").to_pascal_case();
5188        let suffix = Self::status_code_suffix(status_code);
5189        format!("{}Response{}", base_name, suffix)
5190    }
5191
5192    /// Map an OpenAPI status code key to a suffix for generated type names.
5193    ///
5194    /// "200" → "" (unchanged, the dominant case)
5195    /// "201", "400", "404" → "201", "400", "404"
5196    /// "default" → "Default"
5197    /// "4XX" / "4xx" → "4xx" (lowercased range form)
5198    fn status_code_suffix(status_code: &str) -> String {
5199        match status_code {
5200            "" | "200" => String::new(),
5201            "default" | "Default" => "Default".to_string(),
5202            other if other.chars().all(|c| c.is_ascii_digit()) => other.to_string(),
5203            other => other.to_ascii_lowercase(),
5204        }
5205    }
5206
5207    /// Generate a type name for an inline request body schema
5208    fn generate_inline_request_type_name(&self, operation_id: &str) -> String {
5209        use heck::ToPascalCase;
5210        // Convert operation_id to PascalCase and append Request
5211        // e.g., "session.prompt" -> "SessionPromptRequest"
5212        // e.g., "pty.create" -> "PtyCreateRequest"
5213        let base_name = operation_id.replace('.', "_").to_pascal_case();
5214        format!("{}Request", base_name)
5215    }
5216
5217    /// Resolve a schema reference to a name, or inline it with a synthetic name.
5218    /// `suffix` controls the generated name (e.g. "Request" or "Response").
5219    fn resolve_or_inline_schema(
5220        &mut self,
5221        schema: &crate::openapi::Schema,
5222        operation_id: &str,
5223        suffix: &str,
5224    ) -> Result<String> {
5225        if let Some(schema_ref) = schema.reference()
5226            && let Some(schema_name) = self.extract_schema_name(schema_ref)
5227        {
5228            return Ok(schema_name.to_string());
5229        }
5230        // Inline schema - generate a synthetic type name and analyze it
5231        let synthetic_name = if suffix == "Request" {
5232            self.generate_inline_request_type_name(operation_id)
5233        } else {
5234            self.generate_inline_response_type_name(operation_id, "")
5235        };
5236        let mut deps = HashSet::new();
5237        self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
5238        Ok(synthetic_name)
5239    }
5240
5241    /// Resolve a parameter reference ($ref) to the actual parameter definition.
5242    /// Returns the resolved parameter, or the original if it's not a reference.
5243    fn resolve_parameter<'a>(
5244        &'a self,
5245        param: &'a crate::openapi::Parameter,
5246    ) -> std::borrow::Cow<'a, crate::openapi::Parameter> {
5247        if let Some(ref_str) = param.reference.as_deref() {
5248            if let Some(param_name) = ref_str.strip_prefix("#/components/parameters/") {
5249                if let Some(resolved) = self.component_parameters.get(param_name) {
5250                    return std::borrow::Cow::Borrowed(resolved);
5251                }
5252            }
5253        }
5254        std::borrow::Cow::Borrowed(param)
5255    }
5256
5257    /// Analyze a parameter.
5258    ///
5259    /// `operation_id` is used to generate a unique synthetic enum type name
5260    /// when the parameter's inline schema is a string with `enum` or `const`
5261    /// (e.g. `GetItemTheConstant`). The client generator emits the enum
5262    /// alongside the operation methods. See issue #10 follow-up.
5263    /// Look up `#/components/schemas/{name}` in the raw OpenAPI document and
5264    /// decide whether it's a string with enum values. Used by analyze_parameter
5265    /// (T10). String-enum refs flow through to the codegen-typed parameter
5266    /// path; object refs are typed only when form-exploded (issue #27), and
5267    /// other struct refs stay `String` until deepObject / explode=false
5268    /// serialization is generated (T14).
5269    fn referenced_schema_is_string_enum(&self, name: &str) -> bool {
5270        if self.resolve_cached_schema(name).is_some_and(|schema| {
5271            matches!(
5272                schema.schema_type,
5273                SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. }
5274            )
5275        }) {
5276            return true;
5277        }
5278        let Some(schema_value) = self
5279            .openapi_spec
5280            .get("components")
5281            .and_then(|c| c.get("schemas"))
5282            .and_then(|s| s.get(name))
5283        else {
5284            return false;
5285        };
5286        let is_string_type = schema_value
5287            .get("type")
5288            .and_then(|v| v.as_str())
5289            .map(|s| s == "string")
5290            .unwrap_or(false);
5291        let has_enum_or_const =
5292            schema_value.get("enum").is_some() || schema_value.get("const").is_some();
5293        is_string_type && has_enum_or_const
5294    }
5295
5296    fn resolve_raw_local_reference(&self, value: &Value) -> Option<Value> {
5297        let Some(reference) = value.get("$ref").and_then(Value::as_str) else {
5298            return Some(value.clone());
5299        };
5300        let pointer = reference.strip_prefix('#')?;
5301        self.openapi_spec.pointer(pointer).cloned()
5302    }
5303
5304    fn raw_request_body_schema(
5305        &self,
5306        operation: Option<&Value>,
5307        content_type: &str,
5308    ) -> Option<Value> {
5309        let request_body = operation?.get("requestBody")?;
5310        self.resolve_raw_local_reference(request_body)?
5311            .get("content")?
5312            .get(content_type)?
5313            .get("schema")
5314            .cloned()
5315    }
5316
5317    fn raw_parameter_schema(&self, parameter: &Value) -> Option<Value> {
5318        self.resolve_raw_local_reference(parameter)?
5319            .get("schema")
5320            .cloned()
5321    }
5322
5323    fn analyze_parameter(
5324        &mut self,
5325        param: &crate::openapi::Parameter,
5326        operation_id: &str,
5327        raw_validation_schema: Option<Value>,
5328    ) -> Result<Option<ParameterInfo>> {
5329        use heck::ToPascalCase;
5330
5331        let name = param.name.as_deref().unwrap_or("");
5332        let location = param.location.as_deref().unwrap_or("");
5333        let required = param.required.unwrap_or(false);
5334        let validation_schema = match raw_validation_schema {
5335            Some(schema) => Some(schema),
5336            None => param
5337                .schema
5338                .as_ref()
5339                .map(serde_json::to_value)
5340                .transpose()
5341                .map_err(GeneratorError::ParseError)?,
5342        };
5343
5344        let mut rust_type = "String".to_string();
5345        let mut schema_ref = None;
5346        let mut enum_values: Option<Vec<String>> = None;
5347        let mut enum_varnames: Option<Vec<String>> = None;
5348        let mut query_serialization: Option<QuerySerialization> = None;
5349
5350        // OAS 3.x style/explode resolution for `in: query`. Defaults are
5351        // style=form and — for form only — explode=true, so an object/array
5352        // query parameter with nothing specified is already form-exploded
5353        // per spec (issue #27). deepObject is only defined with explode=true;
5354        // an explicit explode=false there is undefined and keeps the fallback.
5355        let is_query = location == "query";
5356        let is_simple_header = location == "header"
5357            && matches!(param.style.as_deref(), None | Some("simple"))
5358            && param.explode != Some(true);
5359        let form_style = matches!(param.style.as_deref(), None | Some("form"));
5360        let form_exploded = form_style && param.explode.unwrap_or(true);
5361        let deep_object =
5362            param.style.as_deref() == Some("deepObject") && param.explode != Some(false);
5363
5364        let object_serialization = if !is_query {
5365            None
5366        } else if deep_object {
5367            Some(QuerySerialization::DeepObject)
5368        } else if form_exploded {
5369            Some(QuerySerialization::FormExplodedObject)
5370        } else if form_style {
5371            Some(QuerySerialization::FormObject)
5372        } else {
5373            None
5374        };
5375
5376        if let Some(schema) = &param.schema {
5377            if let Some(ref_str) = schema.reference() {
5378                // T10: keep the resolved type when the target is a string-enum
5379                // (then `Display`/`as_str` are emitted, see generate_string_enum).
5380                // Object refs on query params with a generated wire style keep
5381                // the resolved struct type too (T14/issue #27); anything else
5382                // stays on the opaque `String` fallback.
5383                if let Some(name) = self.extract_schema_name(ref_str) {
5384                    if self.referenced_schema_is_string_enum(name) {
5385                        schema_ref = Some(name.to_string());
5386                    } else if object_serialization.is_some()
5387                        && self.referenced_schema_is_object(name)
5388                    {
5389                        schema_ref = Some(name.to_string());
5390                        query_serialization = if form_exploded && self.uses_aws_query_conventions()
5391                        {
5392                            match self.referenced_array_struct_item_type(name, 1) {
5393                                Some(ArrayItemType::NestedStructRef { properties, .. }) => {
5394                                    Some(QuerySerialization::FormExplodedNestedObject {
5395                                        properties,
5396                                    })
5397                                }
5398                                _ => object_serialization.clone(),
5399                            }
5400                        } else {
5401                            object_serialization.clone()
5402                        };
5403                    } else if (is_query && form_style || is_simple_header)
5404                        && let Some(item_type) = self.referenced_array_param_item_type(name)
5405                    {
5406                        // A parameter may reference a reusable array schema
5407                        // rather than declaring `type: array` inline. Preserve
5408                        // that component as a pruning root while projecting the
5409                        // public parameter type to the same Vec<T> used by
5410                        // inline arrays.
5411                        schema_ref = Some(name.to_string());
5412                        query_serialization = Some(if is_simple_header {
5413                            QuerySerialization::SimpleHeaderArray { item_type }
5414                        } else if form_exploded {
5415                            QuerySerialization::FormExplodedArray { item_type }
5416                        } else {
5417                            QuerySerialization::FormArray { item_type }
5418                        });
5419                    }
5420                }
5421            } else if object_serialization.is_some() && Self::schema_is_inline_object(schema) {
5422                // Inline object schema on a query parameter with a generated
5423                // wire style: synthesize a struct (e.g. `FindWidgetsFilter`)
5424                // so the caller passes typed fields instead of a pre-encoded
5425                // string.
5426                let op_pascal = operation_id.replace('.', "_").to_pascal_case();
5427                let param_pascal = name.to_pascal_case();
5428                let synthetic_name = format!("{op_pascal}{param_pascal}");
5429                let mut deps = HashSet::new();
5430                self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
5431                schema_ref = Some(synthetic_name.clone());
5432                query_serialization = if form_exploded && self.uses_aws_query_conventions() {
5433                    match self.referenced_array_struct_item_type(&synthetic_name, 1) {
5434                        Some(ArrayItemType::NestedStructRef { properties, .. }) => {
5435                            Some(QuerySerialization::FormExplodedNestedObject { properties })
5436                        }
5437                        _ => object_serialization.clone(),
5438                    }
5439                } else {
5440                    object_serialization.clone()
5441                };
5442            } else if (is_query && form_style || is_simple_header)
5443                && matches!(
5444                    schema.schema_type(),
5445                    Some(crate::openapi::SchemaType::Array)
5446                )
5447                && let Some(item_type) = self.array_param_item_type(schema)
5448            {
5449                // Typed form-style array (openapi-generator-anu): the client
5450                // takes `Vec<item_type>` and emits repeated (explode=true) or
5451                // comma-joined (explode=false) pairs. `rust_type` deliberately
5452                // stays "String" because the shared query-serialization plan
5453                // is the authoritative Vec<T> projection. Arrays whose items
5454                // don't type (objects, nested arrays) fall through to the
5455                // explicit unsupported shape below.
5456                query_serialization = Some(if is_simple_header {
5457                    QuerySerialization::SimpleHeaderArray { item_type }
5458                } else if form_exploded {
5459                    QuerySerialization::FormExplodedArray { item_type }
5460                } else {
5461                    QuerySerialization::FormArray { item_type }
5462                });
5463            } else if let Some(schema_type) = schema.schema_type() {
5464                // Route integer/number through the same TypeMapper the schema
5465                // property path uses (see analyze_property), so `format: int32`
5466                // yields `i32` and `[type_mappings]`/strategy config applies to
5467                // parameters too. Hardcoding `i64`/`f64` here previously made
5468                // `format` and config impossible to honour for query/path params.
5469                let format = schema.details().format.clone();
5470                rust_type = match schema_type {
5471                    crate::openapi::SchemaType::Boolean => "bool".to_string(),
5472                    crate::openapi::SchemaType::Integer => {
5473                        self.type_mapper.integer_format(format.as_deref()).rust_type
5474                    }
5475                    crate::openapi::SchemaType::Number => {
5476                        self.type_mapper.number_format(format.as_deref()).rust_type
5477                    }
5478                    crate::openapi::SchemaType::String => "String".to_string(),
5479                    _ => "String".to_string(),
5480                };
5481
5482                if matches!(schema_type, crate::openapi::SchemaType::String) {
5483                    let details = schema.details();
5484                    if details.is_string_enum() {
5485                        if let Some(values) = details.string_enum_values() {
5486                            if !values.is_empty() {
5487                                let op_pascal = operation_id.replace('.', "_").to_pascal_case();
5488                                let param_pascal = name.to_pascal_case();
5489                                rust_type = format!("{op_pascal}{param_pascal}");
5490                                // Honor `x-enum-varnames` here the same way
5491                                // schema-level enums do. A mismatched length is
5492                                // ambiguous about which value each name refers
5493                                // to, so drop it rather than guess.
5494                                enum_varnames = details
5495                                    .extra
5496                                    .get("x-enum-varnames")
5497                                    .and_then(Value::as_array)
5498                                    .map(|raw| {
5499                                        raw.iter()
5500                                            .filter_map(Value::as_str)
5501                                            .map(str::to_owned)
5502                                            .collect::<Vec<_>>()
5503                                    })
5504                                    .filter(|names| names.len() == values.len());
5505                                enum_values = Some(values);
5506                            }
5507                        }
5508                    }
5509                }
5510            }
5511
5512            if is_query && query_serialization.is_none() {
5513                let referenced_name = schema
5514                    .reference()
5515                    .and_then(|reference| self.extract_schema_name(reference));
5516                let is_object = referenced_name
5517                    .is_some_and(|name| self.referenced_schema_is_object(name))
5518                    || Self::schema_is_inline_object(schema);
5519                let is_array = referenced_name
5520                    .is_some_and(|name| self.referenced_schema_is_array(name))
5521                    || matches!(
5522                        schema.schema_type(),
5523                        Some(crate::openapi::SchemaType::Array)
5524                    );
5525                let is_composed = referenced_name
5526                    .is_some_and(|name| self.referenced_schema_is_composed_query_shape(name));
5527                let reason = if param.style.as_deref() == Some("deepObject")
5528                    && param.explode == Some(false)
5529                {
5530                    Some("style=deepObject with explode=false is undefined by OpenAPI".to_string())
5531                } else if param.style.as_deref() == Some("deepObject") && !is_object {
5532                    Some("style=deepObject is defined only for object query parameters".to_string())
5533                } else if is_object {
5534                    Some(format!(
5535                        "object query parameters do not support style={}",
5536                        param.style.as_deref().unwrap_or("form")
5537                    ))
5538                } else if is_array && form_style {
5539                    Some(
5540                        "form array query parameter exceeds the supported nesting bound or contains a non-scalar leaf; supported shapes are scalar arrays, arrays of flat scalar objects, and one nested scalar-object array"
5541                            .to_string(),
5542                    )
5543                } else if is_array {
5544                    Some(format!(
5545                        "array query parameters do not yet support style={}",
5546                        param.style.as_deref().unwrap_or("form")
5547                    ))
5548                } else if is_composed {
5549                    Some(
5550                        "composed or union query schemas cannot be projected to an unambiguous flat wire shape"
5551                            .to_string(),
5552                    )
5553                } else {
5554                    None
5555                };
5556                if let Some(reason) = reason {
5557                    query_serialization = Some(QuerySerialization::Unsupported { reason });
5558                }
5559            }
5560        }
5561
5562        Ok(Some(ParameterInfo {
5563            name: name.to_string(),
5564            location: location.to_string(),
5565            required,
5566            schema_ref,
5567            rust_type,
5568            description: param.description.clone(),
5569            enum_values,
5570            enum_varnames,
5571            rust_ident: None,
5572            query_serialization,
5573            validation_schema,
5574        }))
5575    }
5576
5577    /// Rust item type for a typed array query parameter
5578    /// (openapi-generator-anu). Scalar items map through the TypeMapper;
5579    /// $ref items resolve when the target is a scalar alias or generated
5580    /// string enum (both support the client/server string wire projection).
5581    /// Anything else — objects, nested arrays — returns None and the
5582    /// parameter keeps the opaque-string fallback. Inline-enum'd string
5583    /// items stay plain `String`: the op-scoped enum synthesis (issue #10)
5584    /// is wired for scalar params only.
5585    fn array_param_item_type(&self, schema: &crate::openapi::Schema) -> Option<ArrayItemType> {
5586        let items = schema.details().items.as_deref()?;
5587        // AWS query-protocol specs wrap item refs in an annotation-only allOf
5588        // (`items: {allOf: [$ref, {xml: ...}]}`). See through the wrapper when
5589        // every sibling is annotation-only, mirroring the type-alias rule.
5590        let unwrapped = unwrap_annotation_allof(items);
5591        if let Some(ref_str) = unwrapped.reference() {
5592            let name = self.extract_schema_name(ref_str)?;
5593            return self
5594                .referenced_array_scalar_item_type(name)
5595                .or_else(|| self.referenced_array_struct_item_type(name, 1));
5596        }
5597        let format = unwrapped.details().format.clone();
5598        let scalar = match unwrapped.schema_type()? {
5599            crate::openapi::SchemaType::String => "String".to_string(),
5600            crate::openapi::SchemaType::Integer => {
5601                self.type_mapper.integer_format(format.as_deref()).rust_type
5602            }
5603            crate::openapi::SchemaType::Number => {
5604                self.type_mapper.number_format(format.as_deref()).rust_type
5605            }
5606            crate::openapi::SchemaType::Boolean => "bool".to_string(),
5607            _ => return None,
5608        };
5609        Some(ArrayItemType::Scalar(scalar))
5610    }
5611
5612    /// Resolve a reusable component array (including `$ref` aliases) and
5613    /// apply the same item projection as an inline array parameter.
5614    fn referenced_array_param_item_type(&self, name: &str) -> Option<ArrayItemType> {
5615        let schema = self.resolve_cached_schema(name)?;
5616        let SchemaType::Array { item_type } = &schema.schema_type else {
5617            return None;
5618        };
5619        self.analyzed_array_item_type(item_type)
5620    }
5621
5622    fn analyzed_array_item_type(&self, item_type: &SchemaType) -> Option<ArrayItemType> {
5623        self.analyzed_array_item_type_at_depth(item_type, 1)
5624    }
5625
5626    /// Accept a referenced structure as a form-style array item when every
5627    /// property is scalar (AWS query-protocol flat structures such as
5628    /// `Tag { Key, Value }`). Nested objects, arrays, and maps are rejected
5629    /// because the wire shape below one level is service-specific.
5630    fn referenced_array_struct_item_type(
5631        &self,
5632        name: &str,
5633        nested_array_depth: usize,
5634    ) -> Option<ArrayItemType> {
5635        let resolved = self.resolve_cached_schema(name)?;
5636        let SchemaType::Object {
5637            properties,
5638            required,
5639            additional_properties,
5640        } = &resolved.schema_type
5641        else {
5642            return None;
5643        };
5644        if properties.is_empty()
5645            || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
5646        {
5647            return None;
5648        }
5649        let mut projected = Vec::with_capacity(properties.len());
5650        let mut has_array = false;
5651        for (wire_name, property) in properties {
5652            let value_type = if let Some(scalar) = self.query_scalar_type(&property.schema_type) {
5653                QueryStructPropertyType::Scalar(scalar)
5654            } else {
5655                if nested_array_depth == 0 {
5656                    return None;
5657                }
5658                if let Some(array) = self.resolve_query_array_type(&property.schema_type) {
5659                    let item_type =
5660                        self.analyzed_array_item_type_at_depth(array, nested_array_depth - 1)?;
5661                    if matches!(item_type, ArrayItemType::NestedStructRef { .. }) {
5662                        return None;
5663                    }
5664                    has_array = true;
5665                    QueryStructPropertyType::Array { item_type }
5666                } else {
5667                    has_array = true;
5668                    QueryStructPropertyType::Object {
5669                        properties: self.query_flat_object_properties(&property.schema_type)?,
5670                    }
5671                }
5672            };
5673            projected.push(QueryStructProperty {
5674                wire_name: wire_name.clone(),
5675                required: required.contains(wire_name),
5676                value_type,
5677            });
5678        }
5679        if has_array {
5680            Some(ArrayItemType::NestedStructRef {
5681                schema_name: name.to_string(),
5682                properties: projected,
5683            })
5684        } else {
5685            Some(ArrayItemType::FlatStructRef {
5686                schema_name: name.to_string(),
5687                properties: projected,
5688            })
5689        }
5690    }
5691
5692    fn analyzed_array_item_type_at_depth(
5693        &self,
5694        item_type: &SchemaType,
5695        nested_array_depth: usize,
5696    ) -> Option<ArrayItemType> {
5697        match item_type {
5698            SchemaType::Primitive { rust_type, .. } => {
5699                Some(ArrayItemType::Scalar(rust_type.clone()))
5700            }
5701            SchemaType::Reference { target } => self
5702                .referenced_array_scalar_item_type(target)
5703                .or_else(|| self.referenced_array_struct_item_type(target, nested_array_depth)),
5704            _ => None,
5705        }
5706    }
5707
5708    fn resolve_query_array_type<'a>(
5709        &'a self,
5710        schema_type: &'a SchemaType,
5711    ) -> Option<&'a SchemaType> {
5712        match schema_type {
5713            SchemaType::Array { item_type } => Some(item_type),
5714            SchemaType::Reference { target } => {
5715                let resolved = self.resolve_cached_schema(target)?;
5716                let SchemaType::Array { item_type } = &resolved.schema_type else {
5717                    return None;
5718                };
5719                Some(item_type)
5720            }
5721            _ => None,
5722        }
5723    }
5724
5725    fn query_flat_object_properties(
5726        &self,
5727        schema_type: &SchemaType,
5728    ) -> Option<Vec<QueryStructProperty>> {
5729        let schema_type = match schema_type {
5730            SchemaType::Reference { target } => &self.resolve_cached_schema(target)?.schema_type,
5731            other => other,
5732        };
5733        let SchemaType::Object {
5734            properties,
5735            required,
5736            additional_properties,
5737        } = schema_type
5738        else {
5739            return None;
5740        };
5741        if properties.is_empty()
5742            || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
5743        {
5744            return None;
5745        }
5746        properties
5747            .iter()
5748            .map(|(wire_name, property)| {
5749                Some(QueryStructProperty {
5750                    wire_name: wire_name.clone(),
5751                    required: required.contains(wire_name),
5752                    value_type: QueryStructPropertyType::Scalar(
5753                        self.query_scalar_type(&property.schema_type)?,
5754                    ),
5755                })
5756            })
5757            .collect()
5758    }
5759
5760    fn query_scalar_type(&self, schema_type: &SchemaType) -> Option<QueryScalarType> {
5761        match schema_type {
5762            SchemaType::Primitive { rust_type, .. } => match rust_type.as_str() {
5763                "String" => Some(QueryScalarType::String),
5764                "bool" => Some(QueryScalarType::Boolean),
5765                value if value.starts_with('i') || value.starts_with('u') => {
5766                    Some(QueryScalarType::Integer)
5767                }
5768                value if value.starts_with('f') => Some(QueryScalarType::Number),
5769                "serde_json::Value" => None,
5770                _ => Some(QueryScalarType::String),
5771            },
5772            SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => {
5773                Some(QueryScalarType::String)
5774            }
5775            SchemaType::Reference { target } => {
5776                let resolved = self.resolve_cached_schema(target)?;
5777                self.query_scalar_type(&resolved.schema_type)
5778            }
5779            _ => None,
5780        }
5781    }
5782
5783    /// Resolve a referenced array item through any alias chain while
5784    /// preserving the outer schema name used by the public `Vec<T>` type.
5785    ///
5786    /// `SchemaType::Primitive` also represents dynamic JSON/object fallbacks,
5787    /// so require an actual OpenAPI scalar `type` before accepting it as a
5788    /// form-style query item. Unresolved and cyclic chains are rejected by
5789    /// `resolve_cached_schema`.
5790    fn referenced_array_scalar_item_type(&self, name: &str) -> Option<ArrayItemType> {
5791        let resolved = self.resolve_cached_schema(name)?;
5792        let supported = match &resolved.schema_type {
5793            SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => true,
5794            SchemaType::Primitive { .. } => resolved
5795                .original
5796                .get("type")
5797                .is_some_and(Self::query_scalar_type_value),
5798            _ => false,
5799        };
5800        supported.then(|| ArrayItemType::SchemaRef(name.to_string()))
5801    }
5802
5803    fn query_scalar_type_value(value: &Value) -> bool {
5804        const SCALARS: [&str; 4] = ["string", "integer", "number", "boolean"];
5805        if let Some(value) = value.as_str() {
5806            return SCALARS.contains(&value);
5807        }
5808        let Some(values) = value.as_array() else {
5809            return false;
5810        };
5811        if !values.iter().all(Value::is_string) {
5812            return false;
5813        }
5814        let mut non_null = values
5815            .iter()
5816            .filter_map(Value::as_str)
5817            .filter(|value| *value != "null");
5818        let Some(scalar) = non_null.next() else {
5819            return false;
5820        };
5821        non_null.next().is_none() && SCALARS.contains(&scalar)
5822    }
5823
5824    /// True when a component (following `$ref` aliases) analyzes to an object.
5825    /// Used to decide whether a referenced query parameter can use a typed
5826    /// object serialization plan (issue #27).
5827    fn referenced_schema_is_object(&self, name: &str) -> bool {
5828        self.resolve_cached_schema(name)
5829            .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Object { .. }))
5830    }
5831
5832    fn referenced_schema_is_array(&self, name: &str) -> bool {
5833        self.resolve_cached_schema(name)
5834            .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Array { .. }))
5835    }
5836
5837    fn referenced_schema_is_composed_query_shape(&self, name: &str) -> bool {
5838        self.resolve_cached_schema(name).is_some_and(|schema| {
5839            matches!(
5840                schema.schema_type,
5841                SchemaType::Composition { .. }
5842                    | SchemaType::Union { .. }
5843                    | SchemaType::DiscriminatedUnion { .. }
5844            )
5845        })
5846    }
5847
5848    fn resolve_cached_schema(&self, name: &str) -> Option<&AnalyzedSchema> {
5849        let mut current = name;
5850        let mut visited = HashSet::new();
5851        loop {
5852            if !visited.insert(current) {
5853                return None;
5854            }
5855            let schema = self.resolved_cache.get(current)?;
5856            if let SchemaType::Reference { target } = &schema.schema_type {
5857                current = target;
5858            } else {
5859                return Some(schema);
5860            }
5861        }
5862    }
5863
5864    /// Inline-schema counterpart of [`Self::referenced_schema_is_object`].
5865    fn schema_is_inline_object(schema: &crate::openapi::Schema) -> bool {
5866        match schema.schema_type() {
5867            Some(crate::openapi::SchemaType::Object) => true,
5868            None => schema.details().properties.is_some(),
5869            _ => false,
5870        }
5871    }
5872}