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(mut openapi_spec: Value, type_mapper: TypeMapper) -> Result<Self> {
1094        disambiguate_component_schema_names(&mut openapi_spec);
1095        let spec: OpenApiSpec =
1096            serde_json::from_value(openapi_spec.clone()).map_err(GeneratorError::ParseError)?;
1097        let schemas = Self::extract_schemas(&spec)?;
1098
1099        let component_parameters = spec
1100            .components
1101            .as_ref()
1102            .and_then(|c| c.parameters.as_ref())
1103            .cloned()
1104            .unwrap_or_default();
1105        Ok(Self {
1106            schemas,
1107            resolved_cache: BTreeMap::new(),
1108            openapi_spec,
1109            current_schema_name: None,
1110            component_parameters,
1111            type_mapper,
1112        })
1113    }
1114
1115    /// Create a new analyzer with schema extensions merged in (default
1116    /// type mapper).
1117    pub fn new_with_extensions(
1118        openapi_spec: Value,
1119        extension_paths: &[std::path::PathBuf],
1120    ) -> Result<Self> {
1121        let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
1122        Self::new(merged_spec)
1123    }
1124
1125    /// Same as [`Self::new_with_extensions`] but with a caller-supplied
1126    /// type mapper.
1127    pub fn new_with_extensions_and_type_mapper(
1128        openapi_spec: Value,
1129        extension_paths: &[std::path::PathBuf],
1130        type_mapper: TypeMapper,
1131    ) -> Result<Self> {
1132        let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
1133        Self::with_type_mapper(merged_spec, type_mapper)
1134    }
1135
1136    /// Borrow the analyzer's type mapper. Useful for downstream
1137    /// inspection (e.g. the dep advisory in Q2.8 reads
1138    /// `type_mapper().used_features()` after generation).
1139    pub fn type_mapper(&self) -> &TypeMapper {
1140        &self.type_mapper
1141    }
1142
1143    /// Generate a context-aware name for inline types, arrays, and variants
1144    /// This provides better naming than generic names like UnionArray1, InlineVariant2, etc.
1145    fn generate_context_aware_name(
1146        &self,
1147        base_context: &str,
1148        type_hint: &str,
1149        index: usize,
1150        schema: Option<&Schema>,
1151    ) -> String {
1152        // First, try to infer a better name from the schema structure
1153        if let Some(schema) = schema {
1154            // For arrays, check if we can derive name from items
1155            if type_hint == "Array"
1156                && matches!(schema.schema_type(), Some(OpenApiSchemaType::Array))
1157            {
1158                if let Some(items_schema) = &schema.details().items {
1159                    // Check for specific item types
1160                    if let Some(item_type) = items_schema.schema_type() {
1161                        match item_type {
1162                            OpenApiSchemaType::Object => {
1163                                return format!("{base_context}ItemArray");
1164                            }
1165                            OpenApiSchemaType::String => {
1166                                return format!("{base_context}StringArray");
1167                            }
1168                            _ => {}
1169                        }
1170                    }
1171                }
1172            }
1173        }
1174
1175        // Generate context-aware name based on type hint
1176        match type_hint {
1177            "Array" => {
1178                // For arrays, always use context name instead of generic numbering
1179                format!("{base_context}Array")
1180            }
1181            "Variant" | "InlineVariant" => {
1182                // For variants, include index only if > 0 to keep first variant clean
1183                if index == 0 {
1184                    format!("{base_context}{type_hint}")
1185                } else {
1186                    format!("{}{}{}", base_context, type_hint, index + 1)
1187                }
1188            }
1189            _ => {
1190                // Default case
1191                format!("{base_context}{type_hint}{index}")
1192            }
1193        }
1194    }
1195
1196    /// Convert a string to PascalCase, handling underscores and hyphens
1197    fn to_pascal_case(&self, s: &str) -> String {
1198        s.split(['_', '-'])
1199            .filter(|part| !part.is_empty())
1200            .map(|part| {
1201                let mut chars = part.chars();
1202                match chars.next() {
1203                    None => String::new(),
1204                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1205                }
1206            })
1207            .collect()
1208    }
1209
1210    fn extract_schemas(spec: &OpenApiSpec) -> Result<BTreeMap<String, Schema>> {
1211        // OAS 3.1+ requires only one of `paths`, `webhooks`, or `components`.
1212        // A document may legitimately have no `components.schemas` (e.g. a
1213        // webhooks-only or paths-only spec). Return an empty map in that case
1214        // and let downstream codegen handle "no types to emit" gracefully.
1215        let schemas = spec.components.as_ref().and_then(|c| c.schemas.as_ref());
1216        Ok(schemas
1217            .map(|m| {
1218                m.iter()
1219                    .map(|(k, v)| (k.clone(), v.clone()))
1220                    .collect::<BTreeMap<_, _>>()
1221            })
1222            .unwrap_or_default())
1223    }
1224
1225    pub fn analyze(&mut self) -> Result<SchemaAnalysis> {
1226        let validation_context = ValidationContext {
1227            openapi_version: self
1228                .openapi_spec
1229                .get("openapi")
1230                .and_then(Value::as_str)
1231                .unwrap_or_default()
1232                .to_string(),
1233            json_schema_dialect: self
1234                .openapi_spec
1235                .get("jsonSchemaDialect")
1236                .and_then(Value::as_str)
1237                .map(str::to_string),
1238            component_schemas: self
1239                .openapi_spec
1240                .pointer("/components/schemas")
1241                .and_then(Value::as_object)
1242                .map(|schemas| {
1243                    schemas
1244                        .iter()
1245                        .map(|(name, schema)| (name.clone(), schema.clone()))
1246                        .collect()
1247                })
1248                .unwrap_or_default(),
1249        };
1250        let mut analysis = SchemaAnalysis {
1251            schemas: BTreeMap::new(),
1252            dependencies: DependencyGraph::new(),
1253            patterns: DetectedPatterns {
1254                tagged_enum_schemas: HashSet::new(),
1255                untagged_enum_schemas: HashSet::new(),
1256                type_mappings: BTreeMap::new(),
1257            },
1258            operations: BTreeMap::new(),
1259            operation_responses: BTreeMap::new(),
1260            operation_id_aliases: BTreeMap::new(),
1261            used_type_features: crate::type_mapping::UsedFeatures::default(),
1262            enum_extensions: BTreeMap::new(),
1263            validation_context,
1264        };
1265
1266        // First pass: detect patterns
1267        self.detect_patterns(&mut analysis.patterns)?;
1268
1269        // Second pass: analyze each schema
1270        let schema_names: Vec<String> = self.schemas.keys().cloned().collect();
1271        for schema_name in schema_names {
1272            let analyzed = self.analyze_schema(&schema_name)?;
1273
1274            // Build dependency graph
1275            for dep in &analyzed.dependencies {
1276                analysis
1277                    .dependencies
1278                    .add_dependency(schema_name.clone(), dep.clone());
1279            }
1280
1281            analysis.schemas.insert(schema_name, analyzed);
1282        }
1283
1284        // Third pass: include any inline schemas that were generated during analysis
1285        // BTreeMap maintains sorted order, so iteration is deterministic
1286        for (inline_name, inline_schema) in &self.resolved_cache {
1287            if !analysis.schemas.contains_key(inline_name) {
1288                // Add the inline schema first
1289                analysis
1290                    .schemas
1291                    .insert(inline_name.clone(), inline_schema.clone());
1292
1293                // Build dependency graph for inline schema's own dependencies
1294                for dep in &inline_schema.dependencies {
1295                    analysis
1296                        .dependencies
1297                        .add_dependency(inline_name.clone(), dep.clone());
1298                }
1299
1300                // Check if any existing schemas depend on this inline schema
1301                // We need to check ALL schemas, not just the ones already in analysis.schemas,
1302                // because parent schemas might have been analyzed but their dependencies
1303                // on inline schemas might not have been added to the dependency graph yet
1304                let mut schemas_to_update = Vec::new();
1305                for (schema_name, schema) in &analysis.schemas {
1306                    // Skip self-reference
1307                    if schema_name == inline_name {
1308                        continue;
1309                    }
1310
1311                    if schema.dependencies.contains(inline_name) {
1312                        // The parent schema depends on this inline schema
1313                        schemas_to_update.push(schema_name.clone());
1314                    }
1315                }
1316
1317                // Add the dependencies to the graph
1318                for schema_name in schemas_to_update {
1319                    analysis
1320                        .dependencies
1321                        .add_dependency(schema_name, inline_name.clone());
1322                }
1323            }
1324        }
1325
1326        // Fourth pass: analyze OpenAPI operations
1327        self.analyze_operations(&mut analysis)?;
1328
1329        // Fifth pass: include any inline schemas generated during operation analysis
1330        // (e.g., inline response types)
1331        for (inline_name, inline_schema) in &self.resolved_cache {
1332            if !analysis.schemas.contains_key(inline_name) {
1333                analysis
1334                    .schemas
1335                    .insert(inline_name.clone(), inline_schema.clone());
1336
1337                // Build dependency graph for inline schema's dependencies
1338                for dep in &inline_schema.dependencies {
1339                    analysis
1340                        .dependencies
1341                        .add_dependency(inline_name.clone(), dep.clone());
1342                }
1343            }
1344        }
1345
1346        disambiguate_analyzed_schema_names(&mut analysis, &self.schemas);
1347
1348        // Snapshot the type-mapper's used-features set so the
1349        // generator can decide which helper modules to emit
1350        // (e.g. base64_serde for `format: byte`).
1351        analysis.used_type_features = self.type_mapper.used_features();
1352
1353        // Q2.6: capture x-enum-varnames / x-enum-descriptions from
1354        // each enum schema's original JSON. Side-channel keyed by
1355        // analyzed-schema name so we don't have to extend every
1356        // SchemaType::StringEnum constructor.
1357        for (name, analyzed) in &analysis.schemas {
1358            let enum_value_count = match &analyzed.schema_type {
1359                SchemaType::StringEnum { values } => values.len(),
1360                SchemaType::ExtensibleEnum { known_values } => known_values.len(),
1361                _ => continue,
1362            };
1363            if let Some(ext) = extract_enum_extensions(&analyzed.original, enum_value_count, name) {
1364                analysis.enum_extensions.insert(name.clone(), ext);
1365            }
1366        }
1367
1368        Ok(analysis)
1369    }
1370
1371    fn detect_patterns(&self, patterns: &mut DetectedPatterns) -> Result<()> {
1372        for (schema_name, schema) in &self.schemas {
1373            // Detect discriminated unions
1374            if self.is_discriminated_union(schema) {
1375                patterns.tagged_enum_schemas.insert(schema_name.clone());
1376
1377                // Extract type mappings for this union
1378                if let Some(mappings) = self.extract_type_mappings(schema)? {
1379                    patterns.type_mappings.insert(schema_name.clone(), mappings);
1380                }
1381            }
1382            // Detect simple unions
1383            else if self.is_simple_union(schema) {
1384                patterns.untagged_enum_schemas.insert(schema_name.clone());
1385            }
1386        }
1387
1388        Ok(())
1389    }
1390
1391    fn is_discriminated_union(&self, schema: &Schema) -> bool {
1392        // Check for explicit discriminator
1393        if schema.is_discriminated_union() {
1394            return true;
1395        }
1396
1397        // Auto-detect from union patterns with any common const field
1398        if let Some(variants) = schema.union_variants() {
1399            return variants.len() > 2 && self.detect_discriminator_field(variants).is_some();
1400        }
1401
1402        false
1403    }
1404
1405    fn all_variants_have_unique_const_values(&self, variants: &[Schema], field_name: &str) -> bool {
1406        let mut values = HashSet::new();
1407
1408        variants.iter().all(|variant| {
1409            let schema = if let Some(ref_str) = variant.reference() {
1410                let Some(schema_name) = self.extract_schema_name(ref_str) else {
1411                    return false;
1412                };
1413                let Some(schema) = self.schemas.get(schema_name) else {
1414                    return false;
1415                };
1416                schema
1417            } else {
1418                variant
1419            };
1420
1421            self.extract_discriminator_value_for_field(schema, field_name)
1422                .is_some_and(|value| values.insert(value))
1423        })
1424    }
1425
1426    /// True when this branch of an anyOf/oneOf is (or resolves to) an
1427    /// object — the only kind of schema serde can deserialize via an
1428    /// internally-tagged enum. False for string/number/bool/array branches
1429    /// or refs to those, including string-enums.
1430    ///
1431    /// Used to detect the "hybrid string-or-object" union pattern (see bug
1432    /// openapi-generator-dpd) so we can downgrade those unions to
1433    /// `#[serde(untagged)]`.
1434    fn branch_resolves_to_object(&self, schema: &Schema) -> bool {
1435        // Follow $ref one hop, then ask the same question of the target.
1436        if let Some(ref_str) = schema.reference() {
1437            return match self
1438                .extract_schema_name(ref_str)
1439                .and_then(|n| self.schemas.get(n))
1440            {
1441                Some(target) => self.branch_resolves_to_object(target),
1442                None => false,
1443            };
1444        }
1445        // allOf compositions are object-shaped; same for anyOf/oneOf
1446        // wrappers (those will reduce to objects or to further unions).
1447        if matches!(
1448            schema,
1449            Schema::AllOf { .. } | Schema::AnyOf { .. } | Schema::OneOf { .. }
1450        ) {
1451            return true;
1452        }
1453        if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object)) {
1454            return true;
1455        }
1456        if schema.inferred_type() == Some(OpenApiSchemaType::Object) {
1457            return true;
1458        }
1459        // Anything else (string, integer, number, boolean, array, null,
1460        // string-enum, etc.) cannot carry a JSON tag field.
1461        false
1462    }
1463
1464    /// Scan all variants to find any common property that has a const/single-enum value
1465    /// across all variants. Returns the field name if found.
1466    /// Prioritizes "type" if it matches (most common convention).
1467    fn detect_discriminator_field(&self, variants: &[Schema]) -> Option<String> {
1468        if variants.is_empty() {
1469            return None;
1470        }
1471
1472        // Collect candidate field names from the first variant
1473        let first_variant = &variants[0];
1474        let first_schema = if let Some(ref_str) = first_variant.reference() {
1475            let schema_name = self.extract_schema_name(ref_str)?;
1476            self.schemas.get(schema_name)?
1477        } else {
1478            first_variant
1479        };
1480
1481        let properties = first_schema.details().properties.as_ref()?;
1482        let mut candidates: Vec<String> = Vec::new();
1483
1484        for (field_name, field_schema) in properties {
1485            let details = field_schema.details();
1486            let is_const = details.const_value.is_some()
1487                || details.enum_values.as_ref().is_some_and(|v| v.len() == 1)
1488                || details.extra.contains_key("const");
1489            if is_const {
1490                candidates.push(field_name.clone());
1491            }
1492        }
1493
1494        if candidates.is_empty() {
1495            return None;
1496        }
1497
1498        // Prioritize "type" if it's among candidates
1499        candidates.sort_by(|a, b| {
1500            if a == "type" {
1501                std::cmp::Ordering::Less
1502            } else if b == "type" {
1503                std::cmp::Ordering::Greater
1504            } else {
1505                a.cmp(b)
1506            }
1507        });
1508
1509        // A discriminator is only useful when every branch has a distinct
1510        // value. Repeated values would generate duplicate serde rename tags,
1511        // making later branches impossible to deserialize. In that case the
1512        // caller falls back to an untagged union so nested const fields can
1513        // participate in matching.
1514        for candidate in &candidates {
1515            if self.all_variants_have_unique_const_values(variants, candidate) {
1516                return Some(candidate.clone());
1517            }
1518        }
1519
1520        None
1521    }
1522
1523    fn is_simple_union(&self, schema: &Schema) -> bool {
1524        if let Some(variants) = schema.union_variants() {
1525            // Simple union: multiple types but not nullable pattern
1526            if variants.len() > 1 && !schema.is_nullable_pattern() {
1527                let has_refs = variants.iter().any(|v| v.is_reference());
1528                return has_refs;
1529            }
1530        }
1531        false
1532    }
1533
1534    fn extract_type_mappings(&self, schema: &Schema) -> Result<Option<BTreeMap<String, String>>> {
1535        let variants = schema.union_variants().ok_or_else(|| {
1536            GeneratorError::InvalidSchema("No variants found for discriminated union".to_string())
1537        })?;
1538
1539        // Get the discriminator field name from the schema
1540        let discriminator_field = if let Some(discriminator) = schema.discriminator() {
1541            discriminator.property_name.clone()
1542        } else if let Some(detected) = self.detect_discriminator_field(variants) {
1543            detected
1544        } else {
1545            "type".to_string() // fallback to "type" for auto-detected discriminated unions
1546        };
1547
1548        let mut mappings = BTreeMap::new();
1549
1550        for variant in variants {
1551            if let Some(ref_str) = variant.reference() {
1552                if let Some(type_name) = self.extract_schema_name(ref_str) {
1553                    if let Some(variant_schema) = self.schemas.get(type_name) {
1554                        if let Some(discriminator_value) = self
1555                            .extract_discriminator_value_for_field(
1556                                variant_schema,
1557                                &discriminator_field,
1558                            )
1559                        {
1560                            mappings.insert(type_name.to_string(), discriminator_value);
1561                        }
1562                    }
1563                }
1564            }
1565        }
1566
1567        if mappings.is_empty() {
1568            Ok(None)
1569        } else {
1570            Ok(Some(mappings))
1571        }
1572    }
1573
1574    #[allow(dead_code)]
1575    fn extract_discriminator_value(&self, schema: &Schema) -> Option<String> {
1576        self.extract_discriminator_value_for_field(schema, "type")
1577    }
1578
1579    fn extract_discriminator_value_for_field(
1580        &self,
1581        schema: &Schema,
1582        field_name: &str,
1583    ) -> Option<String> {
1584        if let Some(properties) = &schema.details().properties {
1585            if let Some(type_field) = properties.get(field_name) {
1586                // Check for const value first (highest priority)
1587                if let Some(const_value) = &type_field.details().const_value {
1588                    if let Some(value) = const_value.as_str() {
1589                        return Some(value.to_string());
1590                    }
1591                }
1592                // Check for enum with single value
1593                if let Some(enum_values) = &type_field.details().enum_values {
1594                    if enum_values.len() == 1 {
1595                        return enum_values[0].as_str().map(|s| s.to_string());
1596                    }
1597                }
1598                // Check for const value in extra fields
1599                if let Some(const_value) = type_field.details().extra.get("const") {
1600                    return const_value.as_str().map(|s| s.to_string());
1601                }
1602                // Check for x-stainless-const with default value
1603                if let Some(stainless_const) = type_field.details().extra.get("x-stainless-const") {
1604                    if stainless_const.as_bool() == Some(true) {
1605                        if let Some(default_value) = &type_field.details().default {
1606                            if let Some(value) = default_value.as_str() {
1607                                return Some(value.to_string());
1608                            }
1609                        }
1610                    }
1611                }
1612            }
1613        }
1614        None
1615    }
1616
1617    fn get_any_reference<'a>(&self, schema: &'a Schema) -> Option<&'a str> {
1618        schema.reference().or_else(|| schema.recursive_reference())
1619    }
1620
1621    fn extract_schema_name<'a>(&self, ref_str: &'a str) -> Option<&'a str> {
1622        if ref_str == "#" {
1623            return None; // Special case for self-reference
1624        }
1625
1626        let parts: Vec<&str> = ref_str.split('/').collect();
1627
1628        // Standard 3.x pattern: #/components/schemas/{SchemaName}[/deeper/path]
1629        if parts.len() >= 4 && parts[0] == "#" && parts[2] == "schemas" {
1630            return Some(parts[3]);
1631        }
1632
1633        // Swagger 2.0 carry-over: some 3.x specs (Google) still use
1634        // `#/definitions/{SchemaName}`. Treat it as an alias.
1635        if parts.len() >= 3 && parts[0] == "#" && parts[1] == "definitions" {
1636            return Some(parts[2]);
1637        }
1638
1639        // Last-segment fallback for other ref shapes — but only if the
1640        // segment plausibly names a top-level schema (PascalCase, no digits-
1641        // only, not a JSON-schema keyword like `schema`/`properties`/`items`).
1642        // pagerduty has `#/components/parameters/foo/schema`, where the last
1643        // segment "schema" is a sub-path indicator, not a schema name.
1644        let last = parts.last()?;
1645        if last.is_empty()
1646            || last.chars().all(|c| c.is_ascii_digit())
1647            || matches!(
1648                *last,
1649                "schema" | "properties" | "items" | "additionalProperties"
1650            )
1651        {
1652            return None;
1653        }
1654        let first = last.chars().next().unwrap_or(' ');
1655        if !first.is_ascii_alphabetic() || !first.is_ascii_uppercase() {
1656            return None;
1657        }
1658        Some(last)
1659    }
1660
1661    fn analyze_schema(&mut self, schema_name: &str) -> Result<AnalyzedSchema> {
1662        // Check cache first
1663        if let Some(cached) = self.resolved_cache.get(schema_name) {
1664            return Ok(cached.clone());
1665        }
1666
1667        // Set current schema name for context
1668        self.current_schema_name = Some(schema_name.to_string());
1669
1670        let schema = self
1671            .schemas
1672            .get(schema_name)
1673            .ok_or_else(|| GeneratorError::UnresolvedReference(schema_name.to_string()))?
1674            .clone();
1675
1676        // Prevent infinite recursion with placeholder
1677        self.resolved_cache.insert(
1678            schema_name.to_string(),
1679            AnalyzedSchema {
1680                name: schema_name.to_string(),
1681                original: serde_json::to_value(&schema).unwrap_or(Value::Null),
1682                schema_type: SchemaType::Reference {
1683                    target: "placeholder".to_string(),
1684                },
1685                dependencies: HashSet::new(),
1686                nullable: false,
1687                description: None,
1688                default: None,
1689            },
1690        );
1691
1692        let analyzed = self.analyze_schema_value(&schema, schema_name)?;
1693
1694        // Update cache with real result
1695        self.resolved_cache
1696            .insert(schema_name.to_string(), analyzed.clone());
1697
1698        Ok(analyzed)
1699    }
1700
1701    fn analyze_schema_value(
1702        &mut self,
1703        schema: &Schema,
1704        schema_name: &str,
1705    ) -> Result<AnalyzedSchema> {
1706        let details = schema.details();
1707        let description = details.description.clone();
1708        // Combine 3.0-style `nullable: true` with 3.1's `type: ["X", "null"]`.
1709        let nullable = details.is_nullable() || schema.type_array_contains_null();
1710        let mut dependencies = HashSet::new();
1711
1712        let schema_type = match schema {
1713            Schema::Reference { reference, .. } => {
1714                // For real-world refs we can't resolve to a known schema name
1715                // (e.g. pagerduty's `#/components/parameters/foo/schema`),
1716                // fall back to opaque JSON instead of failing whole-document
1717                // generation. The rest of the spec is usually unaffected.
1718                match self.extract_schema_name(reference) {
1719                    Some(name) => {
1720                        let target = name.to_string();
1721                        dependencies.insert(target.clone());
1722                        SchemaType::Reference { target }
1723                    }
1724                    None => {
1725                        eprintln!(
1726                            "⚠️  unresolvable $ref `{}` — typing as serde_json::Value",
1727                            reference
1728                        );
1729                        SchemaType::Primitive {
1730                            rust_type: "serde_json::Value".to_string(),
1731                            serde_with: None,
1732                        }
1733                    }
1734                }
1735            }
1736            Schema::RecursiveRef { recursive_ref, .. }
1737            | Schema::DynamicRef {
1738                dynamic_ref: recursive_ref,
1739                ..
1740            } => {
1741                // Handle recursive / dynamic references. J1: full $dynamicRef
1742                // resolution against $dynamicAnchor scopes is a follow-up; for
1743                // now we treat them like recursive refs (self-reference when
1744                // it's a fragment to the same schema, otherwise resolve via
1745                // schema name).
1746                if recursive_ref == "#" {
1747                    dependencies.insert(schema_name.to_string());
1748                    SchemaType::Reference {
1749                        target: schema_name.to_string(),
1750                    }
1751                } else {
1752                    let target = self
1753                        .extract_schema_name(recursive_ref)
1754                        .unwrap_or(schema_name)
1755                        .to_string();
1756                    dependencies.insert(target.clone());
1757                    SchemaType::Reference { target }
1758                }
1759            }
1760            Schema::Typed { .. } | Schema::TypedMulti { .. } => {
1761                let primary = schema
1762                    .schema_type()
1763                    .cloned()
1764                    .unwrap_or(OpenApiSchemaType::Object);
1765                let format = details.format.as_deref();
1766                match primary {
1767                    OpenApiSchemaType::String => {
1768                        if let Some(values) = details.string_enum_values() {
1769                            SchemaType::StringEnum { values }
1770                        } else {
1771                            SchemaType::Primitive {
1772                                rust_type: self.type_mapper.string_format(format).rust_type,
1773                                serde_with: None,
1774                            }
1775                        }
1776                    }
1777                    OpenApiSchemaType::Integer => SchemaType::Primitive {
1778                        rust_type: self.type_mapper.integer_format(format).rust_type,
1779                        serde_with: None,
1780                    },
1781                    OpenApiSchemaType::Number => SchemaType::Primitive {
1782                        rust_type: self.type_mapper.number_format(format).rust_type,
1783                        serde_with: None,
1784                    },
1785                    OpenApiSchemaType::Boolean => SchemaType::Primitive {
1786                        rust_type: self.type_mapper.boolean().rust_type,
1787                        serde_with: None,
1788                    },
1789                    OpenApiSchemaType::Array => {
1790                        // Analyze array item type
1791                        self.analyze_array_schema(schema, schema_name, &mut dependencies)?
1792                    }
1793                    OpenApiSchemaType::Object => {
1794                        // Check if this is a dynamic JSON object
1795                        if self.should_use_dynamic_json(schema) {
1796                            SchemaType::Primitive {
1797                                rust_type: self.type_mapper.dynamic_json().rust_type,
1798                                serde_with: None,
1799                            }
1800                        } else {
1801                            // Analyze object properties
1802                            self.analyze_object_schema(schema, &mut dependencies)?
1803                        }
1804                    }
1805                    _ => SchemaType::Primitive {
1806                        rust_type: self.type_mapper.dynamic_json().rust_type,
1807                        serde_with: None,
1808                    },
1809                }
1810            }
1811            Schema::AnyOf {
1812                any_of,
1813                discriminator,
1814                ..
1815            } => {
1816                // Handle anyOf patterns (nullable vs flexible union vs discriminated)
1817                self.analyze_anyof_union(
1818                    any_of,
1819                    discriminator.as_ref(),
1820                    &mut dependencies,
1821                    schema_name,
1822                )?
1823            }
1824            Schema::OneOf {
1825                one_of,
1826                discriminator,
1827                ..
1828            } => {
1829                // Handle oneOf discriminated unions
1830                self.analyze_oneof_union(
1831                    one_of,
1832                    discriminator.as_ref(),
1833                    schema_name,
1834                    &mut dependencies,
1835                )?
1836            }
1837            Schema::AllOf { all_of, .. } => {
1838                // Handle allOf composition (schema inheritance)
1839                self.analyze_allof_composition(all_of, &mut dependencies)?
1840            }
1841            Schema::Untyped { .. } => {
1842                // Try to infer type from structure
1843                if let Some(inferred) = schema.inferred_type() {
1844                    match inferred {
1845                        OpenApiSchemaType::Object => {
1846                            if self.should_use_dynamic_json(schema) {
1847                                SchemaType::Primitive {
1848                                    rust_type: "serde_json::Value".to_string(),
1849                                    serde_with: None,
1850                                }
1851                            } else {
1852                                self.analyze_object_schema(schema, &mut dependencies)?
1853                            }
1854                        }
1855                        OpenApiSchemaType::String if details.is_string_enum() => {
1856                            SchemaType::StringEnum {
1857                                values: details.string_enum_values().unwrap_or_default(),
1858                            }
1859                        }
1860                        _ => SchemaType::Primitive {
1861                            rust_type: "serde_json::Value".to_string(),
1862                            serde_with: None,
1863                        },
1864                    }
1865                } else {
1866                    SchemaType::Primitive {
1867                        rust_type: "serde_json::Value".to_string(),
1868                        serde_with: None,
1869                    }
1870                }
1871            }
1872        };
1873
1874        Ok(AnalyzedSchema {
1875            name: schema_name.to_string(),
1876            original: serde_json::to_value(schema).unwrap_or(Value::Null), // Convert back to Value for now
1877            schema_type,
1878            dependencies,
1879            nullable,
1880            description,
1881            default: details.default.clone(),
1882        })
1883    }
1884
1885    fn analyze_object_schema(
1886        &mut self,
1887        schema: &Schema,
1888        dependencies: &mut HashSet<String>,
1889    ) -> Result<SchemaType> {
1890        let details = schema.details();
1891        let properties = &details.properties;
1892        let required = details
1893            .required
1894            .as_ref()
1895            .map(|req| req.iter().cloned().collect::<HashSet<String>>())
1896            .unwrap_or_default();
1897
1898        let mut property_info = BTreeMap::new();
1899
1900        if let Some(props) = properties {
1901            for (prop_name, prop_schema) in props {
1902                // Check if this property is a union that needs a named type
1903                let prop_type = if let Schema::AnyOf { any_of, .. } = prop_schema {
1904                    // First check if this should be a dynamic JSON pattern
1905                    if self.should_use_dynamic_json(prop_schema) {
1906                        // This is a dynamic JSON pattern, use serde_json::Value directly
1907                        SchemaType::Primitive {
1908                            rust_type: "serde_json::Value".to_string(),
1909                            serde_with: None,
1910                        }
1911                    } else if prop_schema.is_nullable_pattern()
1912                        && let Some(non_null) = prop_schema.non_null_variant()
1913                    {
1914                        // 3.1 idiom: `anyOf: [<schema>, {type: null}]`. The
1915                        // wrapper has no semantic value beyond nullability;
1916                        // unwrap to the inner type. Without this, the synthesized
1917                        // wrapper type collides with the inner $ref's name when
1918                        // the property name produces a colliding parent context
1919                        // (e.g. `Step.status` → `StepStatus`, which is also the
1920                        // referenced component).
1921                        self.analyze_property_schema_with_context(
1922                            non_null,
1923                            Some(prop_name),
1924                            dependencies,
1925                        )?
1926                    } else {
1927                        // This is an anyOf union in a property - create a named union type
1928                        // Use the current schema name as context to make the union name unique
1929                        let context_name = self
1930                            .current_schema_name
1931                            .clone()
1932                            .unwrap_or_else(|| "Unknown".to_string());
1933
1934                        // Generate a name based on both the schema and property name
1935                        let prop_pascal = self.to_pascal_case(prop_name);
1936                        let mut union_type_name = format!("{context_name}{prop_pascal}");
1937
1938                        // Avoid colliding with an existing component schema or
1939                        // an inline name that's already in resolved_cache.
1940                        if self.schemas.contains_key(&union_type_name)
1941                            || self.resolved_cache.contains_key(&union_type_name)
1942                        {
1943                            let mut suffix = 2;
1944                            loop {
1945                                let candidate = format!("{union_type_name}Union{suffix}");
1946                                if !self.schemas.contains_key(&candidate)
1947                                    && !self.resolved_cache.contains_key(&candidate)
1948                                {
1949                                    union_type_name = candidate;
1950                                    break;
1951                                }
1952                                suffix += 1;
1953                                if suffix > 1000 {
1954                                    break;
1955                                }
1956                            }
1957                        }
1958
1959                        // Analyze the union
1960                        let union_schema_type = self.analyze_anyof_union(
1961                            any_of,
1962                            prop_schema.discriminator(),
1963                            dependencies,
1964                            &union_type_name,
1965                        )?;
1966
1967                        // Store the union as a named schema
1968                        self.resolved_cache.insert(
1969                            union_type_name.clone(),
1970                            AnalyzedSchema {
1971                                name: union_type_name.clone(),
1972                                original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
1973                                schema_type: union_schema_type,
1974                                dependencies: HashSet::new(),
1975                                nullable: false,
1976                                description: prop_schema.details().description.clone(),
1977                                default: None,
1978                            },
1979                        );
1980
1981                        // Return a reference to the named union type
1982                        dependencies.insert(union_type_name.clone());
1983                        SchemaType::Reference {
1984                            target: union_type_name,
1985                        }
1986                    }
1987                } else if let Schema::OneOf {
1988                    one_of,
1989                    discriminator,
1990                    ..
1991                } = prop_schema
1992                {
1993                    // 3.1 idiom: `oneOf: [<schema>, {type: null}]`. Same
1994                    // unwrap as anyOf above — without this, the synthesized
1995                    // wrapper type collides with the inner $ref's name
1996                    // (discord's `QuarantineUserAction.metadata` →
1997                    // `QuarantineUserActionMetadata` clashing with the
1998                    // referenced `QuarantineUserActionMetadata` schema).
1999                    if prop_schema.is_nullable_pattern()
2000                        && let Some(non_null) = prop_schema.non_null_variant()
2001                    {
2002                        let unwrapped = self.analyze_property_schema_with_context(
2003                            non_null,
2004                            Some(prop_name),
2005                            dependencies,
2006                        )?;
2007                        let prop_details = prop_schema.details();
2008                        let prop_nullable = true;
2009                        let prop_description = prop_details.description.clone();
2010                        let prop_default = prop_details.default.clone();
2011                        property_info.insert(
2012                            prop_name.clone(),
2013                            PropertyInfo {
2014                                schema_type: unwrapped,
2015                                nullable: prop_nullable,
2016                                description: prop_description,
2017                                default: prop_default,
2018                                serde_attrs: Vec::new(),
2019                                constraints: PropertyConstraints::from_schema_details(prop_details),
2020                            },
2021                        );
2022                        continue;
2023                    }
2024
2025                    // Handle oneOf discriminated unions in properties
2026                    let context_name = self
2027                        .current_schema_name
2028                        .clone()
2029                        .unwrap_or_else(|| "Unknown".to_string());
2030                    let prop_pascal = self.to_pascal_case(prop_name);
2031                    let mut union_type_name = format!("{context_name}{prop_pascal}");
2032                    // Same collision-suffix dance as the anyOf branch above.
2033                    if self.schemas.contains_key(&union_type_name)
2034                        || self.resolved_cache.contains_key(&union_type_name)
2035                    {
2036                        let mut suffix = 2;
2037                        loop {
2038                            let candidate = format!("{union_type_name}Union{suffix}");
2039                            if !self.schemas.contains_key(&candidate)
2040                                && !self.resolved_cache.contains_key(&candidate)
2041                            {
2042                                union_type_name = candidate;
2043                                break;
2044                            }
2045                            suffix += 1;
2046                            if suffix > 1000 {
2047                                break;
2048                            }
2049                        }
2050                    }
2051
2052                    // Analyze the discriminated union
2053                    let union_schema_type = self.analyze_oneof_union(
2054                        one_of,
2055                        discriminator.as_ref(),
2056                        &union_type_name,
2057                        dependencies,
2058                    )?;
2059
2060                    // Store the union as a named schema
2061                    self.resolved_cache.insert(
2062                        union_type_name.clone(),
2063                        AnalyzedSchema {
2064                            name: union_type_name.clone(),
2065                            original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
2066                            schema_type: union_schema_type,
2067                            dependencies: HashSet::new(),
2068                            nullable: false,
2069                            description: prop_schema.details().description.clone(),
2070                            default: None,
2071                        },
2072                    );
2073
2074                    // Return a reference to the named union type
2075                    dependencies.insert(union_type_name.clone());
2076                    SchemaType::Reference {
2077                        target: union_type_name,
2078                    }
2079                } else {
2080                    // Regular property schema analysis - pass property name for context
2081                    self.analyze_property_schema_with_context(
2082                        prop_schema,
2083                        Some(prop_name),
2084                        dependencies,
2085                    )?
2086                };
2087
2088                let prop_details = prop_schema.details();
2089                // Every nullability form, via one helper — see is_nullable_any.
2090                let prop_nullable = prop_schema.is_nullable_any();
2091                let prop_description = prop_details.description.clone();
2092                let prop_default = prop_details.default.clone();
2093
2094                property_info.insert(
2095                    prop_name.clone(),
2096                    PropertyInfo {
2097                        schema_type: prop_type,
2098                        nullable: prop_nullable,
2099                        description: prop_description,
2100                        default: prop_default,
2101                        serde_attrs: Vec::new(),
2102                        constraints: PropertyConstraints::from_schema_details(prop_details),
2103                    },
2104                );
2105            }
2106        }
2107
2108        // Q2.3: classify additionalProperties three ways. When the
2109        // spec gives us a schema we analyze it and emit a typed
2110        // BTreeMap<String, T>; pre-Q2.3 collapsed both Schema and
2111        // Boolean(true) to the same untyped map. Toggle:
2112        //   [generator.types.shape] additional_properties_typed
2113        // Default true; setting false reverts the schema case to
2114        // Untyped (current pre-Q2.3 behavior).
2115        let typed_enabled = self
2116            .type_mapper
2117            .config()
2118            .shape
2119            .as_ref()
2120            .and_then(|s| s.additional_properties_typed)
2121            .unwrap_or(true);
2122
2123        let additional_properties = match &details.additional_properties {
2124            Some(crate::openapi::AdditionalProperties::Boolean(true)) => {
2125                ObjectAdditionalProperties::Untyped
2126            }
2127            Some(crate::openapi::AdditionalProperties::Boolean(false)) => {
2128                ObjectAdditionalProperties::Forbidden
2129            }
2130            Some(crate::openapi::AdditionalProperties::Schema(value_schema)) if typed_enabled => {
2131                let analyzed =
2132                    self.analyze_property_schema_with_context(value_schema, None, dependencies)?;
2133                ObjectAdditionalProperties::Typed {
2134                    value_type: Box::new(analyzed),
2135                }
2136            }
2137            Some(crate::openapi::AdditionalProperties::Schema(_)) => {
2138                // typed_enabled = false: degrade to the pre-Q2.3 behavior.
2139                ObjectAdditionalProperties::Untyped
2140            }
2141            None => ObjectAdditionalProperties::Forbidden,
2142        };
2143
2144        Ok(SchemaType::Object {
2145            properties: property_info,
2146            required,
2147            additional_properties,
2148        })
2149    }
2150
2151    fn analyze_property_schema_with_context(
2152        &mut self,
2153        schema: &Schema,
2154        property_name: Option<&str>,
2155        dependencies: &mut HashSet<String>,
2156    ) -> Result<SchemaType> {
2157        if let Some(ref_str) = self.get_any_reference(schema) {
2158            let target_opt = if ref_str == "#" {
2159                Some(
2160                    self.find_recursive_anchor_schema()
2161                        .unwrap_or_else(|| "UnknownRecursive".to_string()),
2162                )
2163            } else {
2164                self.extract_schema_name(ref_str).map(|s| s.to_string())
2165            };
2166            match target_opt {
2167                Some(target) => {
2168                    dependencies.insert(target.clone());
2169                    return Ok(SchemaType::Reference { target });
2170                }
2171                None => {
2172                    eprintln!(
2173                        "⚠️  unresolvable $ref `{}` — typing as serde_json::Value",
2174                        ref_str
2175                    );
2176                    return Ok(SchemaType::Primitive {
2177                        rust_type: "serde_json::Value".to_string(),
2178                        serde_with: None,
2179                    });
2180                }
2181            }
2182        }
2183
2184        if let Some(schema_type) = schema.schema_type() {
2185            match schema_type {
2186                OpenApiSchemaType::String => {
2187                    // Check if this string type has enum values
2188                    if let Some(enum_values) = schema.details().string_enum_values() {
2189                        // This is an inline enum in a property - create a named enum type
2190                        // Use the current schema name as context to make the enum name unique
2191                        let context_name = self
2192                            .current_schema_name
2193                            .clone()
2194                            .unwrap_or_else(|| "Unknown".to_string());
2195
2196                        // Generate a candidate name based on both the schema and property context.
2197                        let primary_name = if let Some(prop_name) = property_name {
2198                            // We have property name context - use it for a unique name
2199                            let prop_pascal = self.to_pascal_case(prop_name);
2200                            format!("{context_name}{prop_pascal}")
2201                        } else {
2202                            // No property name context - generate a unique name using enum values
2203                            // Use the first enum value to help make the name unique
2204                            let suffix = if !enum_values.is_empty() {
2205                                let first_value = self.to_pascal_case(&enum_values[0]);
2206                                format!("{first_value}Enum")
2207                            } else {
2208                                "StringEnum".to_string()
2209                            };
2210                            format!("{context_name}{suffix}")
2211                        };
2212
2213                        return Ok(self.hoist_inline_string_enum(
2214                            schema,
2215                            enum_values,
2216                            primary_name,
2217                            dependencies,
2218                        ));
2219                    } else {
2220                        // Property-level string with no enum values:
2221                        // route through TypeMapper so `format: date-time`
2222                        // / `uuid` / etc. surface as typed scalars
2223                        // (chrono::DateTime, uuid::Uuid, …) instead of
2224                        // collapsing to bare `String`.
2225                        let mapped = self
2226                            .type_mapper
2227                            .string_format(schema.details().format.as_deref());
2228                        return Ok(SchemaType::Primitive {
2229                            rust_type: mapped.rust_type,
2230                            serde_with: mapped.serde_with,
2231                        });
2232                    }
2233                }
2234                OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
2235                    let details = schema.details();
2236                    let rust_type = self.get_number_rust_type(schema_type.clone(), details);
2237                    return Ok(SchemaType::Primitive {
2238                        rust_type,
2239                        serde_with: None,
2240                    });
2241                }
2242                OpenApiSchemaType::Boolean => {
2243                    return Ok(SchemaType::Primitive {
2244                        rust_type: "bool".to_string(),
2245                        serde_with: None,
2246                    });
2247                }
2248                OpenApiSchemaType::Array => {
2249                    // Analyze array property with context
2250                    let context_name = if let Some(prop_name) = property_name {
2251                        // Use property name for context
2252                        let prop_pascal = self.to_pascal_case(prop_name);
2253                        format!(
2254                            "{}{}",
2255                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2256                            prop_pascal
2257                        )
2258                    } else {
2259                        // Fallback to generic name
2260                        "ArrayItem".to_string()
2261                    };
2262                    return self.analyze_array_schema(schema, &context_name, dependencies);
2263                }
2264                OpenApiSchemaType::Object => {
2265                    // Check if this is a dynamic JSON object
2266                    if self.should_use_dynamic_json(schema) {
2267                        return Ok(SchemaType::Primitive {
2268                            rust_type: "serde_json::Value".to_string(),
2269                            serde_with: None,
2270                        });
2271                    }
2272                    // Inline object in property - create a named schema for it
2273                    let object_type_name = if let Some(prop_name) = property_name {
2274                        // Use property name for context
2275                        let prop_pascal = self.to_pascal_case(prop_name);
2276                        format!(
2277                            "{}{}",
2278                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2279                            prop_pascal
2280                        )
2281                    } else {
2282                        // Fallback to generic name
2283                        format!(
2284                            "{}Object",
2285                            self.current_schema_name.as_deref().unwrap_or("Unknown")
2286                        )
2287                    };
2288
2289                    // Analyze the object schema
2290                    let object_type = self.analyze_object_schema(schema, dependencies)?;
2291
2292                    // Create an analyzed schema for the inline object
2293                    let inline_schema = AnalyzedSchema {
2294                        name: object_type_name.clone(),
2295                        original: serde_json::to_value(schema).unwrap_or(Value::Null),
2296                        schema_type: object_type,
2297                        dependencies: dependencies.clone(),
2298                        nullable: false,
2299                        description: schema.details().description.clone(),
2300                        default: None,
2301                    };
2302
2303                    // Add the inline object as a named schema
2304                    self.resolved_cache
2305                        .insert(object_type_name.clone(), inline_schema);
2306                    dependencies.insert(object_type_name.clone());
2307
2308                    // Return a reference to the named schema
2309                    return Ok(SchemaType::Reference {
2310                        target: object_type_name,
2311                    });
2312                }
2313                _ => {
2314                    return Ok(SchemaType::Primitive {
2315                        rust_type: "serde_json::Value".to_string(),
2316                        serde_with: None,
2317                    });
2318                }
2319            }
2320        }
2321
2322        // Handle nullable patterns
2323        if schema.is_nullable_pattern() {
2324            if let Some(non_null) = schema.non_null_variant() {
2325                return self.analyze_property_schema_with_context(
2326                    non_null,
2327                    property_name,
2328                    dependencies,
2329                );
2330            }
2331        }
2332
2333        // Check if this should be dynamic JSON before further analysis
2334        if self.should_use_dynamic_json(schema) {
2335            return Ok(SchemaType::Primitive {
2336                rust_type: "serde_json::Value".to_string(),
2337                serde_with: None,
2338            });
2339        }
2340
2341        // Handle allOf composition patterns
2342        if let Schema::AllOf { all_of, .. } = schema {
2343            return self.analyze_allof_composition(all_of, dependencies);
2344        }
2345
2346        // Handle union patterns (anyOf/oneOf) that weren't caught earlier
2347        if let Some(variants) = schema.union_variants() {
2348            match variants.len().cmp(&1) {
2349                std::cmp::Ordering::Equal => {
2350                    // Single variant - analyze it directly
2351                    return self.analyze_property_schema_with_context(
2352                        &variants[0],
2353                        property_name,
2354                        dependencies,
2355                    );
2356                }
2357                std::cmp::Ordering::Greater => {
2358                    // Multiple variants - try to analyze as a union
2359                    // Generate a context-aware name for the union type
2360                    let union_name = if let Some(prop_name) = property_name {
2361                        // We have property context - create a proper union name
2362                        let prop_pascal = self.to_pascal_case(prop_name);
2363                        format!(
2364                            "{}{}",
2365                            self.current_schema_name.as_deref().unwrap_or(""),
2366                            prop_pascal
2367                        )
2368                    } else {
2369                        "UnionType".to_string()
2370                    };
2371
2372                    // Check if this is a oneOf or anyOf
2373                    if let Schema::OneOf {
2374                        one_of,
2375                        discriminator,
2376                        ..
2377                    } = schema
2378                    {
2379                        // This is a oneOf - analyze it properly with potential discriminator
2380                        let oneof_result = self.analyze_oneof_union(
2381                            one_of,
2382                            discriminator.as_ref(),
2383                            &union_name,
2384                            dependencies,
2385                        )?;
2386
2387                        // If we got a union type (not discriminated), we need to store it as a named type
2388                        if let SchemaType::Union {
2389                            variants: _union_variants,
2390                        } = &oneof_result
2391                        {
2392                            // Store the union as a named type in resolved_cache
2393                            self.resolved_cache.insert(
2394                                union_name.clone(),
2395                                AnalyzedSchema {
2396                                    name: union_name.clone(),
2397                                    original: serde_json::to_value(schema).unwrap_or(Value::Null),
2398                                    schema_type: oneof_result.clone(),
2399                                    dependencies: dependencies.clone(),
2400                                    nullable: false,
2401                                    description: schema.details().description.clone(),
2402                                    default: None,
2403                                },
2404                            );
2405
2406                            // Return a reference to the named union type
2407                            dependencies.insert(union_name.clone());
2408                            return Ok(SchemaType::Reference { target: union_name });
2409                        }
2410
2411                        return Ok(oneof_result);
2412                    } else if let Schema::AnyOf {
2413                        any_of,
2414                        discriminator,
2415                        ..
2416                    } = schema
2417                    {
2418                        // This is anyOf - use existing logic with discriminator support
2419                        let union_analysis = self.analyze_anyof_union(
2420                            any_of,
2421                            discriminator.as_ref(),
2422                            dependencies,
2423                            &union_name,
2424                        )?;
2425                        return Ok(union_analysis);
2426                    } else {
2427                        // This shouldn't happen, but handle gracefully
2428                        // Create a simple union from variants
2429                        let mut union_variants = Vec::new();
2430                        for variant in variants {
2431                            if let Some(ref_str) = variant.reference() {
2432                                if let Some(target) = self.extract_schema_name(ref_str) {
2433                                    dependencies.insert(target.to_string());
2434                                    union_variants.push(SchemaRef {
2435                                        target: target.to_string(),
2436                                        nullable: false,
2437                                    });
2438                                }
2439                            }
2440                        }
2441                        return Ok(SchemaType::Union {
2442                            variants: union_variants,
2443                        });
2444                    }
2445                }
2446                std::cmp::Ordering::Less => {}
2447            }
2448        }
2449
2450        // Handle untyped schemas by trying to infer from structure
2451        if let Some(inferred_type) = schema.inferred_type() {
2452            match inferred_type {
2453                OpenApiSchemaType::Object => {
2454                    // Double-check for dynamic JSON pattern even for inferred objects
2455                    if self.should_use_dynamic_json(schema) {
2456                        return Ok(SchemaType::Primitive {
2457                            rust_type: "serde_json::Value".to_string(),
2458                            serde_with: None,
2459                        });
2460                    }
2461                    return self.analyze_object_schema(schema, dependencies);
2462                }
2463                OpenApiSchemaType::Array => {
2464                    let context_name = if let Some(prop_name) = property_name {
2465                        // Use property name for context
2466                        let prop_pascal = self.to_pascal_case(prop_name);
2467                        format!(
2468                            "{}{}",
2469                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2470                            prop_pascal
2471                        )
2472                    } else {
2473                        // Fallback to generic name
2474                        "ArrayItem".to_string()
2475                    };
2476                    return self.analyze_array_schema(schema, &context_name, dependencies);
2477                }
2478                OpenApiSchemaType::String => {
2479                    if let Some(enum_values) = schema.details().string_enum_values() {
2480                        return Ok(SchemaType::StringEnum {
2481                            values: enum_values,
2482                        });
2483                    } else {
2484                        return Ok(SchemaType::Primitive {
2485                            rust_type: "String".to_string(),
2486                            serde_with: None,
2487                        });
2488                    }
2489                }
2490                _ => {
2491                    // Handle other inferred types
2492                    let rust_type = self.openapi_type_to_rust_type(inferred_type, schema.details());
2493                    return Ok(SchemaType::Primitive {
2494                        rust_type,
2495                        serde_with: None,
2496                    });
2497                }
2498            }
2499        }
2500
2501        Ok(SchemaType::Primitive {
2502            rust_type: "serde_json::Value".to_string(),
2503            serde_with: None,
2504        })
2505    }
2506
2507    fn analyze_allof_composition(
2508        &mut self,
2509        all_of_schemas: &[Schema],
2510        dependencies: &mut HashSet<String>,
2511    ) -> Result<SchemaType> {
2512        // A reference plus annotation-only siblings is still a direct type
2513        // alias. AWS-style specs frequently encode property descriptions as
2514        // `allOf: [$ref, { description: ... }]`; recursively expanding a
2515        // self-reference in that shape can otherwise recurse forever.
2516        let referenced_targets = all_of_schemas
2517            .iter()
2518            .filter_map(|schema| schema.reference())
2519            .filter_map(|reference| self.extract_schema_name(reference))
2520            .collect::<Vec<_>>();
2521        let only_reference_and_annotations = all_of_schemas.iter().all(|schema| {
2522            if schema.reference().is_some() {
2523                return true;
2524            }
2525            serde_json::to_value(schema)
2526                .ok()
2527                .and_then(|value| value.as_object().cloned())
2528                .is_some_and(|object| {
2529                    object.keys().all(|key| {
2530                        matches!(
2531                            key.as_str(),
2532                            "title"
2533                                | "description"
2534                                | "deprecated"
2535                                | "readOnly"
2536                                | "writeOnly"
2537                                | "examples"
2538                                | "example"
2539                                | "externalDocs"
2540                                | "xml"
2541                                | "$comment"
2542                        ) || key.starts_with("x-")
2543                    })
2544                })
2545        });
2546        if referenced_targets.len() == 1 && only_reference_and_annotations {
2547            let target = referenced_targets[0];
2548            dependencies.insert(target.to_string());
2549            return Ok(SchemaType::Reference {
2550                target: target.to_string(),
2551            });
2552        }
2553
2554        // AllOf represents schema composition - merge all schemas into one
2555        let mut merged_properties = BTreeMap::new();
2556        let mut merged_required = HashSet::new();
2557        let mut descriptions = Vec::new();
2558
2559        // Save the current schema context to restore it when analyzing properties
2560        let current_context = self.current_schema_name.clone();
2561
2562        for schema in all_of_schemas {
2563            match schema {
2564                Schema::Reference { reference, .. } => {
2565                    // Add dependency on referenced schema
2566                    if let Some(target) = self.extract_schema_name(reference) {
2567                        dependencies.insert(target.to_string());
2568
2569                        // First ensure the referenced schema is analyzed
2570                        let analyzed_ref = self.analyze_schema(target)?;
2571
2572                        // Now merge the analyzed schema's properties
2573                        match &analyzed_ref.schema_type {
2574                            SchemaType::Object {
2575                                properties,
2576                                required,
2577                                ..
2578                            } => {
2579                                // Merge properties from the analyzed schema
2580                                for (prop_name, prop_info) in properties {
2581                                    merged_properties.insert(prop_name.clone(), prop_info.clone());
2582                                }
2583                                // Merge required fields
2584                                for req in required {
2585                                    merged_required.insert(req.clone());
2586                                }
2587                            }
2588                            _ => {
2589                                // If the referenced schema is not an object, fall back to raw merge
2590                                if let Some(ref_schema) = self.schemas.get(target).cloned() {
2591                                    self.merge_schema_into_properties(
2592                                        &ref_schema,
2593                                        &mut merged_properties,
2594                                        &mut merged_required,
2595                                        dependencies,
2596                                    )?;
2597                                }
2598                            }
2599                        }
2600                    }
2601                }
2602                Schema::Typed {
2603                    schema_type: OpenApiSchemaType::Object,
2604                    ..
2605                }
2606                | Schema::Untyped { .. } => {
2607                    // Restore the original context when analyzing inline properties
2608                    let saved_context = self.current_schema_name.clone();
2609                    self.current_schema_name = current_context.clone();
2610
2611                    // Merge object properties directly
2612                    self.merge_schema_into_properties(
2613                        schema,
2614                        &mut merged_properties,
2615                        &mut merged_required,
2616                        dependencies,
2617                    )?;
2618
2619                    // Restore the previous context
2620                    self.current_schema_name = saved_context;
2621                }
2622                _ => {
2623                    // For non-object typed schemas in allOf, try to merge them as well
2624                    // This handles cases like allOf with enum or string constraints
2625                    self.merge_schema_into_properties(
2626                        schema,
2627                        &mut merged_properties,
2628                        &mut merged_required,
2629                        dependencies,
2630                    )?;
2631                }
2632            }
2633
2634            // Collect descriptions
2635            if let Some(desc) = &schema.details().description {
2636                descriptions.push(desc.clone());
2637            }
2638        }
2639
2640        // If we successfully merged properties, return an object
2641        if !merged_properties.is_empty() {
2642            Ok(SchemaType::Object {
2643                properties: merged_properties,
2644                required: merged_required,
2645                additional_properties: ObjectAdditionalProperties::Forbidden,
2646            })
2647        } else {
2648            // Fall back to composition if we couldn't merge
2649            Ok(SchemaType::Composition {
2650                schemas: all_of_schemas
2651                    .iter()
2652                    .filter_map(|s| {
2653                        if let Some(ref_str) = s.reference() {
2654                            if let Some(target) = self.extract_schema_name(ref_str) {
2655                                dependencies.insert(target.to_string());
2656                                Some(SchemaRef {
2657                                    target: target.to_string(),
2658                                    nullable: false,
2659                                })
2660                            } else {
2661                                None
2662                            }
2663                        } else {
2664                            None
2665                        }
2666                    })
2667                    .collect(),
2668            })
2669        }
2670    }
2671
2672    fn merge_schema_into_properties(
2673        &mut self,
2674        schema: &Schema,
2675        merged_properties: &mut BTreeMap<String, PropertyInfo>,
2676        merged_required: &mut HashSet<String>,
2677        dependencies: &mut HashSet<String>,
2678    ) -> Result<()> {
2679        let details = schema.details();
2680
2681        // Merge properties
2682        if let Some(properties) = &details.properties {
2683            for (prop_name, prop_schema) in properties {
2684                let prop_type = self.analyze_property_schema_with_context(
2685                    prop_schema,
2686                    Some(prop_name),
2687                    dependencies,
2688                )?;
2689                let prop_details = prop_schema.details();
2690
2691                // Properties merged through allOf composition must go through
2692                // the same nullability check as plain object properties.
2693                // Real hits: OpenAI Response.incomplete_details (anyOf-with-null,
2694                // openapi-generator-bgo) and RunPod Pod.startedAt / Pod.template
2695                // (3.1 type-array, openapi-generator-dsu) — the latter arrive
2696                // as `null` from the live API for any pod that hasn't started.
2697                let nullable = prop_schema.is_nullable_any();
2698                merged_properties.insert(
2699                    prop_name.clone(),
2700                    PropertyInfo {
2701                        schema_type: prop_type,
2702                        nullable,
2703                        description: prop_details.description.clone(),
2704                        default: prop_details.default.clone(),
2705                        serde_attrs: Vec::new(),
2706                        constraints: PropertyConstraints::from_schema_details(prop_details),
2707                    },
2708                );
2709            }
2710        }
2711
2712        // Merge required fields
2713        if let Some(required) = &details.required {
2714            for field in required {
2715                merged_required.insert(field.clone());
2716            }
2717        }
2718
2719        Ok(())
2720    }
2721
2722    fn analyze_oneof_union(
2723        &mut self,
2724        one_of_schemas: &[Schema],
2725        discriminator: Option<&crate::openapi::Discriminator>,
2726        parent_name: &str,
2727        dependencies: &mut HashSet<String>,
2728    ) -> Result<SchemaType> {
2729        // Pattern: nullable [Type, null] — return the non-null type directly.
2730        // The nullable bit is recorded at the property level via is_nullable_pattern().
2731        if one_of_schemas.len() == 2 {
2732            let null_count = one_of_schemas
2733                .iter()
2734                .filter(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2735                .count();
2736            if null_count == 1 {
2737                if let Some(non_null) = one_of_schemas
2738                    .iter()
2739                    .find(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2740                {
2741                    return self
2742                        .analyze_schema_value(non_null, parent_name)
2743                        .map(|a| a.schema_type);
2744                }
2745            }
2746        }
2747
2748        // If there's no discriminator, we should create an untagged union
2749        if discriminator.is_none() {
2750            // Handle untagged unions (oneOf without discriminator)
2751            return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
2752        }
2753
2754        // Bug openapi-generator-dpd: if any branch resolves to a non-object
2755        // schema (e.g. a string-enum like ToolChoiceOptions), serde cannot
2756        // deserialize it via an internally-tagged enum because there is no
2757        // JSON object to read the tag from. Fall back to an untagged union
2758        // so the scalar branch can still match.
2759        if one_of_schemas
2760            .iter()
2761            .any(|s| !self.branch_resolves_to_object(s))
2762        {
2763            return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
2764        }
2765
2766        // This is a discriminated union
2767        let discriminator_field = discriminator
2768            .ok_or_else(|| {
2769                GeneratorError::InvalidDiscriminator(
2770                    "expected discriminator after guard check".to_string(),
2771                )
2772            })?
2773            .property_name
2774            .clone();
2775
2776        let mut variants = Vec::new();
2777        let mut used_variant_names = std::collections::HashSet::new();
2778
2779        for variant_schema in one_of_schemas {
2780            // Check if this is a direct reference, recursive reference, or an allOf wrapper with a reference
2781            let ref_info = if let Some(ref_str) = variant_schema.reference() {
2782                Some((ref_str, false))
2783            } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2784                Some((recursive_ref, true))
2785            } else if let Schema::AllOf { all_of, .. } = variant_schema {
2786                // Check if this is an allOf with a single reference
2787                if all_of.len() == 1 {
2788                    if let Some(ref_str) = all_of[0].reference() {
2789                        Some((ref_str, false))
2790                    } else {
2791                        all_of[0]
2792                            .recursive_reference()
2793                            .map(|recursive_ref| (recursive_ref, true))
2794                    }
2795                } else {
2796                    None
2797                }
2798            } else {
2799                None
2800            };
2801
2802            if let Some((ref_str, is_recursive)) = ref_info {
2803                let schema_name = if is_recursive && ref_str == "#" {
2804                    // Handle recursive reference to the schema with recursiveAnchor
2805                    self.find_recursive_anchor_schema()
2806                        .or_else(|| self.current_schema_name.clone())
2807                        .unwrap_or_else(|| "CompoundFilter".to_string())
2808                } else {
2809                    self.extract_schema_name(ref_str)
2810                        .map(|s| s.to_string())
2811                        .unwrap_or_else(|| "UnknownRef".to_string())
2812                };
2813
2814                if !schema_name.is_empty() {
2815                    dependencies.insert(schema_name.clone());
2816
2817                    // Determine discriminator value with priority order:
2818                    // 1. Explicit mapping in discriminator
2819                    // 2. Extract from referenced schema
2820                    // 3. Generate from schema name
2821                    let discriminator_value = if let Some(disc) = discriminator {
2822                        if let Some(mappings) = &disc.mapping {
2823                            // Find the mapping key that points to this schema reference
2824                            // Mapping format is: "discriminator_value" -> "#/components/schemas/SchemaName"
2825                            mappings
2826                                .iter()
2827                                .find(|(_, target_ref)| {
2828                                    // Check if this mapping target matches our reference
2829                                    target_ref.as_str() == ref_str
2830                                        || self
2831                                            .extract_schema_name(target_ref)
2832                                            .map(|s| s.to_string())
2833                                            == Some(schema_name.clone())
2834                                })
2835                                .map(|(key, _)| key.clone())
2836                                .unwrap_or_else(|| {
2837                                    self.fallback_discriminator_value_for_field(
2838                                        &schema_name,
2839                                        &discriminator_field,
2840                                    )
2841                                })
2842                        } else {
2843                            self.fallback_discriminator_value_for_field(
2844                                &schema_name,
2845                                &discriminator_field,
2846                            )
2847                        }
2848                    } else {
2849                        self.fallback_discriminator_value_for_field(
2850                            &schema_name,
2851                            &discriminator_field,
2852                        )
2853                    };
2854
2855                    // Generate Rust-friendly variant name and ensure uniqueness
2856                    let base_name = self.to_rust_variant_name(&schema_name);
2857                    let rust_name =
2858                        self.ensure_unique_variant_name(base_name, &mut used_variant_names);
2859
2860                    // Use the discriminator value as-is from the schema
2861                    let final_discriminator_value = discriminator_value;
2862
2863                    variants.push(UnionVariant {
2864                        rust_name,
2865                        type_name: schema_name,
2866                        discriminator_value: final_discriminator_value,
2867                        schema_ref: ref_str.to_string(),
2868                    });
2869                }
2870            } else {
2871                // Handle inline schemas in oneOf
2872                let variant_index = variants.len();
2873                let inline_type_name =
2874                    self.generate_inline_type_name(variant_schema, variant_index);
2875
2876                // Try to extract discriminator value from inline schema
2877                let discriminator_value = if let Some(disc) = discriminator {
2878                    if let Some(mappings) = &disc.mapping {
2879                        // Look for mapping that points to this inline variant by index
2880                        mappings
2881                            .iter()
2882                            .find(|(_, target_ref)| {
2883                                target_ref.contains(&format!("variant_{variant_index}"))
2884                            })
2885                            .map(|(key, _)| key.clone())
2886                            .unwrap_or_else(|| {
2887                                self.extract_inline_discriminator_value(
2888                                    variant_schema,
2889                                    &discriminator_field,
2890                                    variant_index,
2891                                )
2892                            })
2893                    } 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
2908                // Generate Rust-friendly variant name based on discriminator or fallback to generic
2909                let base_name = if discriminator_value.starts_with("variant_") {
2910                    format!("Variant{variant_index}")
2911                } else {
2912                    // Convert discriminator value to a meaningful Rust variant name
2913                    let clean_name = self.discriminator_to_variant_name(&discriminator_value);
2914                    self.to_rust_variant_name(&clean_name)
2915                };
2916                let rust_name = self.ensure_unique_variant_name(base_name, &mut used_variant_names);
2917
2918                // Use the discriminator value as-is from the schema
2919                let final_discriminator_value = discriminator_value;
2920
2921                variants.push(UnionVariant {
2922                    rust_name,
2923                    type_name: inline_type_name.clone(),
2924                    discriminator_value: final_discriminator_value,
2925                    schema_ref: format!("inline_{variant_index}"),
2926                });
2927
2928                // Store inline schema for later analysis and generation
2929                self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
2930            }
2931        }
2932
2933        if variants.is_empty() {
2934            // If we couldn't create a discriminated union, fall back to an untagged union
2935            // This handles cases where oneOf contains references or inline schemas without proper discriminators
2936            let mut union_variants = Vec::new();
2937
2938            for (variant_index, variant_schema) in one_of_schemas.iter().enumerate() {
2939                // First check if it's a reference or recursive reference
2940                if let Some(ref_str) = variant_schema.reference() {
2941                    if let Some(schema_name) = self.extract_schema_name(ref_str) {
2942                        dependencies.insert(schema_name.to_string());
2943                        union_variants.push(SchemaRef {
2944                            target: schema_name.to_string(),
2945                            nullable: false,
2946                        });
2947                    }
2948                } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2949                    let schema_name = if recursive_ref == "#" {
2950                        // Handle recursive reference to the schema with recursiveAnchor
2951                        self.find_recursive_anchor_schema()
2952                            .or_else(|| self.current_schema_name.clone())
2953                            .unwrap_or_else(|| "CompoundFilter".to_string())
2954                    } else {
2955                        self.extract_schema_name(recursive_ref)
2956                            .map(|s| s.to_string())
2957                            .unwrap_or_else(|| "RecursiveType".to_string())
2958                    };
2959                    dependencies.insert(schema_name.clone());
2960                    union_variants.push(SchemaRef {
2961                        target: schema_name,
2962                        nullable: false,
2963                    });
2964                } else {
2965                    // Handle inline schemas by creating type aliases or using primitive types directly
2966                    let inline_name = self.generate_context_aware_name(
2967                        parent_name,
2968                        "InlineVariant",
2969                        variant_index,
2970                        Some(variant_schema),
2971                    );
2972                    let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
2973                    let variant_type = analyzed.schema_type;
2974
2975                    // Add dependencies from the analyzed schema
2976                    for dep in &analyzed.dependencies {
2977                        dependencies.insert(dep.clone());
2978                    }
2979
2980                    match &variant_type {
2981                        // For primitive types, we can use them directly in the union
2982                        SchemaType::Primitive { rust_type, .. } => {
2983                            union_variants.push(SchemaRef {
2984                                target: rust_type.clone(),
2985                                nullable: false,
2986                            });
2987                        }
2988                        // For arrays, check if we can determine the item type
2989                        SchemaType::Array { item_type } => {
2990                            match item_type.as_ref() {
2991                                SchemaType::Primitive { rust_type, .. } => {
2992                                    let type_name = format!("Vec<{rust_type}>");
2993                                    union_variants.push(SchemaRef {
2994                                        target: type_name,
2995                                        nullable: false,
2996                                    });
2997                                }
2998                                SchemaType::Reference { target } => {
2999                                    let type_name = format!("Vec<{target}>");
3000                                    union_variants.push(SchemaRef {
3001                                        target: type_name,
3002                                        nullable: false,
3003                                    });
3004                                }
3005                                _ => {
3006                                    // For other array types, create an inline type
3007                                    let inline_type_name = self.generate_context_aware_name(
3008                                        parent_name,
3009                                        "Variant",
3010                                        variant_index,
3011                                        None,
3012                                    );
3013                                    self.add_inline_schema(
3014                                        &inline_type_name,
3015                                        variant_schema,
3016                                        dependencies,
3017                                    )?;
3018                                    union_variants.push(SchemaRef {
3019                                        target: inline_type_name,
3020                                        nullable: false,
3021                                    });
3022                                }
3023                            }
3024                        }
3025                        // For reference types, use the reference target directly
3026                        SchemaType::Reference { target } => {
3027                            union_variants.push(SchemaRef {
3028                                target: target.clone(),
3029                                nullable: false,
3030                            });
3031                        }
3032                        // For other complex types, create an inline type
3033                        _ => {
3034                            let inline_type_name =
3035                                format!("{}Variant{}", parent_name, variant_index + 1);
3036                            self.add_inline_schema(
3037                                &inline_type_name,
3038                                variant_schema,
3039                                dependencies,
3040                            )?;
3041                            union_variants.push(SchemaRef {
3042                                target: inline_type_name,
3043                                nullable: false,
3044                            });
3045                        }
3046                    }
3047                }
3048            }
3049
3050            if !union_variants.is_empty() {
3051                return Ok(SchemaType::Union {
3052                    variants: union_variants,
3053                });
3054            }
3055
3056            // Only fall back to serde_json::Value if we truly can't analyze the union
3057            return Ok(SchemaType::Primitive {
3058                rust_type: "serde_json::Value".to_string(),
3059                serde_with: None,
3060            });
3061        }
3062
3063        Ok(SchemaType::DiscriminatedUnion {
3064            discriminator_field,
3065            variants,
3066        })
3067    }
3068
3069    fn analyze_untagged_oneof_union(
3070        &mut self,
3071        one_of_schemas: &[Schema],
3072        parent_name: &str,
3073        dependencies: &mut HashSet<String>,
3074    ) -> Result<SchemaType> {
3075        // Drop {"type": "null"} variants. They mean "may be null" and are surfaced
3076        // as Option<T> at the property level — including them here produces a junk
3077        // `SerdeJsonValue(serde_json::Value)` variant.
3078        let filtered: Vec<&Schema> = one_of_schemas
3079            .iter()
3080            .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
3081            .collect();
3082
3083        // If filtering leaves a single variant, return its analyzed type directly.
3084        if filtered.len() == 1 {
3085            return self
3086                .analyze_schema_value(filtered[0], parent_name)
3087                .map(|a| a.schema_type);
3088        }
3089
3090        let mut union_variants = Vec::new();
3091
3092        for (variant_index, variant_schema) in filtered.iter().copied().enumerate() {
3093            // First check if it's a reference or recursive reference
3094            if let Some(ref_str) = variant_schema.reference() {
3095                if let Some(schema_name) = self.extract_schema_name(ref_str) {
3096                    dependencies.insert(schema_name.to_string());
3097                    union_variants.push(SchemaRef {
3098                        target: schema_name.to_string(),
3099                        nullable: false,
3100                    });
3101                }
3102            } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
3103                let schema_name = if recursive_ref == "#" {
3104                    // Handle recursive reference to the schema with recursiveAnchor
3105                    self.find_recursive_anchor_schema()
3106                        .or_else(|| self.current_schema_name.clone())
3107                        .unwrap_or_else(|| "CompoundFilter".to_string())
3108                } else {
3109                    self.extract_schema_name(recursive_ref)
3110                        .map(|s| s.to_string())
3111                        .unwrap_or_else(|| "RecursiveType".to_string())
3112                };
3113                dependencies.insert(schema_name.clone());
3114                union_variants.push(SchemaRef {
3115                    target: schema_name,
3116                    nullable: false,
3117                });
3118            } else {
3119                // Handle inline schemas by creating type aliases or using primitive types directly
3120                let inline_name = self.generate_context_aware_name(
3121                    parent_name,
3122                    "InlineVariant",
3123                    variant_index,
3124                    Some(variant_schema),
3125                );
3126                let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
3127                let variant_type = analyzed.schema_type;
3128
3129                // Add dependencies from the analyzed schema
3130                for dep in &analyzed.dependencies {
3131                    dependencies.insert(dep.clone());
3132                }
3133
3134                match &variant_type {
3135                    // For primitive types, we can use them directly in the union
3136                    SchemaType::Primitive { rust_type, .. } => {
3137                        union_variants.push(SchemaRef {
3138                            target: rust_type.clone(),
3139                            nullable: false,
3140                        });
3141                    }
3142                    // For arrays, check if we can determine the item type
3143                    SchemaType::Array { item_type } => {
3144                        match item_type.as_ref() {
3145                            SchemaType::Primitive { rust_type, .. } => {
3146                                let type_name = format!("Vec<{rust_type}>");
3147                                union_variants.push(SchemaRef {
3148                                    target: type_name,
3149                                    nullable: false,
3150                                });
3151                            }
3152                            SchemaType::Reference { target } => {
3153                                let type_name = format!("Vec<{target}>");
3154                                union_variants.push(SchemaRef {
3155                                    target: type_name,
3156                                    nullable: false,
3157                                });
3158                            }
3159                            // Handle arrays of arrays (e.g., Vec<Vec<i64>>)
3160                            SchemaType::Array {
3161                                item_type: inner_item_type,
3162                            } => {
3163                                match inner_item_type.as_ref() {
3164                                    SchemaType::Primitive { rust_type, .. } => {
3165                                        let type_name = format!("Vec<Vec<{rust_type}>>");
3166                                        union_variants.push(SchemaRef {
3167                                            target: type_name,
3168                                            nullable: false,
3169                                        });
3170                                    }
3171                                    SchemaType::Reference { target } => {
3172                                        let type_name = format!("Vec<Vec<{target}>>");
3173                                        union_variants.push(SchemaRef {
3174                                            target: type_name,
3175                                            nullable: false,
3176                                        });
3177                                    }
3178                                    _ => {
3179                                        // For deeper nesting, create an inline type
3180                                        let inline_type_name = self.generate_context_aware_name(
3181                                            parent_name,
3182                                            "Variant",
3183                                            variant_index,
3184                                            None,
3185                                        );
3186                                        self.add_inline_schema(
3187                                            &inline_type_name,
3188                                            variant_schema,
3189                                            dependencies,
3190                                        )?;
3191                                        union_variants.push(SchemaRef {
3192                                            target: inline_type_name,
3193                                            nullable: false,
3194                                        });
3195                                    }
3196                                }
3197                            }
3198                            _ => {
3199                                // For other array types, create an inline type
3200                                let inline_type_name = self.generate_context_aware_name(
3201                                    parent_name,
3202                                    "Variant",
3203                                    variant_index,
3204                                    None,
3205                                );
3206                                self.add_inline_schema(
3207                                    &inline_type_name,
3208                                    variant_schema,
3209                                    dependencies,
3210                                )?;
3211                                union_variants.push(SchemaRef {
3212                                    target: inline_type_name,
3213                                    nullable: false,
3214                                });
3215                            }
3216                        }
3217                    }
3218                    // For reference types, use the reference target directly
3219                    SchemaType::Reference { target } => {
3220                        union_variants.push(SchemaRef {
3221                            target: target.clone(),
3222                            nullable: false,
3223                        });
3224                    }
3225                    // For other complex types, create an inline type
3226                    _ => {
3227                        let inline_type_name = self.generate_context_aware_name(
3228                            parent_name,
3229                            "Variant",
3230                            variant_index,
3231                            None,
3232                        );
3233                        self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
3234                        union_variants.push(SchemaRef {
3235                            target: inline_type_name,
3236                            nullable: false,
3237                        });
3238                    }
3239                }
3240            }
3241        }
3242
3243        if !union_variants.is_empty() {
3244            return Ok(SchemaType::Union {
3245                variants: union_variants,
3246            });
3247        }
3248
3249        // Only fall back to serde_json::Value if we truly can't analyze the union
3250        Ok(SchemaType::Primitive {
3251            rust_type: "serde_json::Value".to_string(),
3252            serde_with: None,
3253        })
3254    }
3255
3256    fn add_inline_schema(
3257        &mut self,
3258        type_name: &str,
3259        schema: &Schema,
3260        dependencies: &mut HashSet<String>,
3261    ) -> Result<()> {
3262        // For primitive types, we need to ensure they are stored as type aliases
3263        if let Some(schema_type) = schema.schema_type() {
3264            match schema_type {
3265                OpenApiSchemaType::String
3266                | OpenApiSchemaType::Integer
3267                | OpenApiSchemaType::Number
3268                | OpenApiSchemaType::Boolean => {
3269                    let rust_type =
3270                        self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
3271
3272                    // Store as a type alias
3273                    self.resolved_cache.insert(
3274                        type_name.to_string(),
3275                        AnalyzedSchema {
3276                            name: type_name.to_string(),
3277                            original: serde_json::to_value(schema).unwrap_or(Value::Null),
3278                            schema_type: SchemaType::Primitive {
3279                                rust_type,
3280                                serde_with: None,
3281                            },
3282                            dependencies: HashSet::new(),
3283                            nullable: false,
3284                            description: schema.details().description.clone(),
3285                            default: None,
3286                        },
3287                    );
3288                    return Ok(());
3289                }
3290                _ => {}
3291            }
3292        }
3293
3294        // For non-primitive types, analyze the inline schema and add it to our collection
3295        // Set current_schema_name so nested inline properties (enums, unions, objects)
3296        // get named with the correct parent context instead of inheriting a stale name
3297        let previous_schema_name = self.current_schema_name.take();
3298        self.current_schema_name = Some(type_name.to_string());
3299        let analyzed = self.analyze_schema_value(schema, type_name)?;
3300        self.current_schema_name = previous_schema_name;
3301
3302        // Add to resolved cache so it can be generated
3303        self.resolved_cache.insert(type_name.to_string(), analyzed);
3304
3305        // Add dependencies
3306        if let Some(cached) = self.resolved_cache.get(type_name) {
3307            for dep in &cached.dependencies {
3308                dependencies.insert(dep.clone());
3309            }
3310        }
3311
3312        Ok(())
3313    }
3314
3315    fn extract_inline_discriminator_value(
3316        &self,
3317        schema: &Schema,
3318        discriminator_field: &str,
3319        variant_index: usize,
3320    ) -> String {
3321        // Try to extract discriminator value from inline schema properties
3322        if let Some(properties) = &schema.details().properties {
3323            if let Some(discriminator_prop) = properties.get(discriminator_field) {
3324                // Check for enum with single value
3325                if let Some(enum_values) = &discriminator_prop.details().enum_values {
3326                    if enum_values.len() == 1 {
3327                        if let Some(value) = enum_values[0].as_str() {
3328                            return value.to_string();
3329                        }
3330                    }
3331                }
3332                // Check for const value in extra fields
3333                if let Some(const_value) = discriminator_prop.details().extra.get("const") {
3334                    if let Some(value) = const_value.as_str() {
3335                        return value.to_string();
3336                    }
3337                }
3338                // Check for const value in the discriminator_prop.details().const_value
3339                if let Some(const_value) = &discriminator_prop.details().const_value {
3340                    if let Some(value) = const_value.as_str() {
3341                        return value.to_string();
3342                    }
3343                }
3344            }
3345        }
3346
3347        // Try to infer from schema structure and properties
3348        if let Some(inferred_name) = self.infer_variant_name_from_structure(schema, variant_index) {
3349            return inferred_name;
3350        }
3351
3352        // Fall back to generic variant name
3353        format!("variant_{variant_index}")
3354    }
3355
3356    fn infer_variant_name_from_structure(
3357        &self,
3358        schema: &Schema,
3359        _variant_index: usize,
3360    ) -> Option<String> {
3361        let details = schema.details();
3362
3363        // Strategy 1: Look for unique property combinations that suggest the variant type
3364        if let Some(properties) = &details.properties {
3365            // Common patterns for content blocks
3366            if properties.contains_key("text") && properties.len() <= 3 {
3367                return Some("text".to_string());
3368            }
3369            if properties.contains_key("image") || properties.contains_key("source") {
3370                return Some("image".to_string());
3371            }
3372            if properties.contains_key("document") {
3373                return Some("document".to_string());
3374            }
3375            if properties.contains_key("tool_use_id") || properties.contains_key("tool_result") {
3376                return Some("tool_result".to_string());
3377            }
3378            if properties.contains_key("content") && properties.contains_key("is_error") {
3379                return Some("tool_result".to_string());
3380            }
3381            if properties.contains_key("partial_json") {
3382                return Some("partial_json".to_string());
3383            }
3384
3385            // Strategy 2: Look for properties that hint at the variant purpose
3386            let property_names: Vec<&String> = properties.keys().collect();
3387
3388            // Try to find the most descriptive property name
3389            for prop_name in &property_names {
3390                if prop_name.contains("result") {
3391                    return Some("result".to_string());
3392                }
3393                if prop_name.contains("error") {
3394                    return Some("error".to_string());
3395                }
3396                if prop_name.contains("content") && property_names.len() <= 2 {
3397                    return Some("content".to_string());
3398                }
3399            }
3400
3401            // Strategy 3: Use the most significant unique property
3402            let significant_props = property_names
3403                .iter()
3404                .filter(|&name| !["type", "id", "cache_control"].contains(&name.as_str()))
3405                .collect::<Vec<_>>();
3406
3407            if significant_props.len() == 1 {
3408                return Some((*significant_props[0]).clone());
3409            }
3410        }
3411
3412        // Strategy 4: Look at description for hints
3413        if let Some(description) = &details.description {
3414            let desc_lower = description.to_lowercase();
3415            if desc_lower.contains("text") && desc_lower.len() < 100 {
3416                return Some("text".to_string());
3417            }
3418            if desc_lower.contains("image") {
3419                return Some("image".to_string());
3420            }
3421            if desc_lower.contains("document") {
3422                return Some("document".to_string());
3423            }
3424            if desc_lower.contains("tool") && desc_lower.contains("result") {
3425                return Some("tool_result".to_string());
3426            }
3427        }
3428
3429        None
3430    }
3431
3432    fn discriminator_to_variant_name(&self, discriminator: &str) -> String {
3433        // Convert discriminator values to PascalCase variant names using general rules
3434        if discriminator.is_empty() {
3435            return "Variant".to_string();
3436        }
3437
3438        let mut result = String::new();
3439        let mut next_upper = true;
3440
3441        for c in discriminator.chars() {
3442            match c {
3443                'a'..='z' => {
3444                    if next_upper {
3445                        result.push(c.to_ascii_uppercase());
3446                        next_upper = false;
3447                    } else {
3448                        result.push(c);
3449                    }
3450                }
3451                'A'..='Z' => {
3452                    result.push(c);
3453                    next_upper = false;
3454                }
3455                '0'..='9' => {
3456                    result.push(c);
3457                    next_upper = false;
3458                }
3459                '_' | '-' | '.' | ' ' | '/' | '\\' => {
3460                    // Word separators - next char should be uppercase
3461                    next_upper = true;
3462                }
3463                _ => {
3464                    // Other special characters - treat as word boundary
3465                    next_upper = true;
3466                }
3467            }
3468        }
3469
3470        // Ensure it starts with a letter
3471        if result.is_empty() || result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
3472            result = format!("Variant{result}");
3473        }
3474
3475        result
3476    }
3477
3478    fn ensure_unique_variant_name(
3479        &self,
3480        base_name: String,
3481        used_names: &mut std::collections::HashSet<String>,
3482    ) -> String {
3483        let mut candidate = base_name.clone();
3484        let mut counter = 1;
3485
3486        while used_names.contains(&candidate) {
3487            counter += 1;
3488            candidate = format!("{base_name}{counter}");
3489        }
3490
3491        used_names.insert(candidate.clone());
3492        candidate
3493    }
3494
3495    fn generate_inline_type_name(&self, schema: &Schema, variant_index: usize) -> String {
3496        // Try to generate a meaningful name for inline schemas
3497        if let Some(meaningful_name) = self.infer_type_name_from_structure(schema) {
3498            return meaningful_name;
3499        }
3500
3501        // Fallback to context-aware name
3502        let context = self.current_schema_name.as_deref().unwrap_or("Inline");
3503        self.generate_context_aware_name(context, "Variant", variant_index, Some(schema))
3504    }
3505
3506    fn infer_type_name_from_structure(&self, schema: &Schema) -> Option<String> {
3507        let details = schema.details();
3508
3509        // Strategy 1: Use description if it's short and descriptive
3510        if let Some(description) = &details.description {
3511            if let Some(name_from_desc) = self.extract_type_name_from_description(description) {
3512                return Some(name_from_desc);
3513            }
3514        }
3515
3516        // Strategy 2: Use the most significant property name as the type identifier
3517        if let Some(properties) = &details.properties {
3518            if let Some(name_from_props) = self.extract_type_name_from_properties(properties) {
3519                return Some(format!("{name_from_props}Block"));
3520            }
3521        }
3522
3523        None
3524    }
3525
3526    fn extract_type_name_from_description(&self, description: &str) -> Option<String> {
3527        // Only use descriptions that are short and likely to be type identifiers
3528        if description.len() > 100 || description.contains('\n') {
3529            return None;
3530        }
3531
3532        // Extract the first meaningful word(s) from the description
3533        let words: Vec<&str> = description
3534            .split_whitespace()
3535            .take(2) // Only take first 2 words to avoid long names
3536            .filter(|word| {
3537                let w = word.to_lowercase();
3538                word.len() > 2
3539                    && ![
3540                        "the", "and", "for", "with", "that", "this", "are", "can", "will", "was",
3541                    ]
3542                    .contains(&w.as_str())
3543            })
3544            .collect();
3545
3546        if words.is_empty() {
3547            return None;
3548        }
3549
3550        // Convert to PascalCase using our existing logic
3551        let combined = words.join("_");
3552        let pascal_name = self.discriminator_to_variant_name(&combined);
3553
3554        // Add suffix if it doesn't already have one
3555        if !pascal_name.ends_with("Content")
3556            && !pascal_name.ends_with("Block")
3557            && !pascal_name.ends_with("Type")
3558        {
3559            Some(format!("{pascal_name}Content"))
3560        } else {
3561            Some(pascal_name)
3562        }
3563    }
3564
3565    fn extract_type_name_from_properties(
3566        &self,
3567        properties: &std::collections::BTreeMap<String, crate::openapi::Schema>,
3568    ) -> Option<String> {
3569        // Get property names, excluding common structural properties
3570        let significant_props: Vec<&String> = properties
3571            .keys()
3572            .filter(|name| !["type", "id", "cache_control"].contains(&name.as_str()))
3573            .collect();
3574
3575        if significant_props.is_empty() {
3576            return None;
3577        }
3578
3579        // Strategy 1: If there's only one significant property, use it
3580        if significant_props.len() == 1 {
3581            let prop_name = significant_props[0];
3582            return Some(self.discriminator_to_variant_name(prop_name));
3583        }
3584
3585        // Strategy 2: Use the first property alphabetically for consistency
3586        // This provides deterministic naming without hardcoded preferences
3587        let mut sorted_props = significant_props.clone();
3588        sorted_props.sort();
3589        if let Some(first_prop) = sorted_props.first() {
3590            return Some(self.discriminator_to_variant_name(first_prop));
3591        }
3592
3593        None
3594    }
3595
3596    fn openapi_type_to_rust_type(
3597        &self,
3598        openapi_type: OpenApiSchemaType,
3599        details: &crate::openapi::SchemaDetails,
3600    ) -> String {
3601        // Q2.0: route through the TypeMapper chokepoint. With the default
3602        // config this produces bit-identical output to the pre-refactor
3603        // match; later Q2.* issues add format-aware branches inside
3604        // TypeMapper without touching this function.
3605        self.type_mapper.map(openapi_type, details).rust_type
3606    }
3607
3608    #[allow(dead_code)]
3609    fn fallback_discriminator_value(&self, schema_name: &str) -> String {
3610        self.fallback_discriminator_value_for_field(schema_name, "type")
3611    }
3612
3613    fn fallback_discriminator_value_for_field(
3614        &self,
3615        schema_name: &str,
3616        field_name: &str,
3617    ) -> String {
3618        // Try to extract from referenced schema first
3619        if let Some(ref_schema) = self.schemas.get(schema_name) {
3620            if let Some(extracted) =
3621                self.extract_discriminator_value_for_field(ref_schema, field_name)
3622            {
3623                return extracted;
3624            }
3625        }
3626
3627        // Fall back to generating from name
3628        self.generate_discriminator_value_from_name(schema_name)
3629    }
3630
3631    fn generate_discriminator_value_from_name(&self, schema_name: &str) -> String {
3632        // Convert schema names like "ResponseCreatedEvent" to "response.created"
3633        let mut result = String::new();
3634        let mut chars = schema_name.chars().peekable();
3635        let mut first = true;
3636
3637        while let Some(c) = chars.next() {
3638            if c.is_uppercase()
3639                && !first
3640                && chars
3641                    .peek()
3642                    .map(|&next| next.is_lowercase())
3643                    .unwrap_or(false)
3644            {
3645                result.push('.');
3646            }
3647            result.push(c.to_ascii_lowercase());
3648            first = false;
3649        }
3650
3651        // Remove common suffixes
3652        if result.ends_with("event") {
3653            result = result[..result.len() - 5].to_string();
3654        }
3655
3656        // Add "response." prefix if it looks like a response event
3657        if schema_name.starts_with("Response") && !result.starts_with("response.") {
3658            result = format!("response.{}", result.trim_start_matches("response"));
3659        }
3660
3661        result
3662    }
3663
3664    fn to_rust_variant_name(&self, schema_name: &str) -> String {
3665        // Convert "ResponseCreatedEvent" to "Created", "UserStatus" to "UserStatus", etc.
3666        let mut name = schema_name;
3667
3668        // Remove common prefixes for cleaner variant names
3669        if name.starts_with("Response") && name.len() > 8 {
3670            name = &name[8..]; // Remove "Response"
3671        }
3672
3673        // Remove common suffixes
3674        if name.ends_with("Event") && name.len() > 5 {
3675            name = &name[..name.len() - 5]; // Remove "Event"
3676        }
3677
3678        // Trim leading and trailing underscores
3679        name = name.trim_matches('_');
3680
3681        // Convert underscores to camel case using our existing function
3682        if name.is_empty() {
3683            schema_name.to_string()
3684        } else {
3685            // Use discriminator_to_variant_name to properly handle underscores
3686            self.discriminator_to_variant_name(name)
3687        }
3688    }
3689
3690    /// Register an inline string enum as a named `StringEnum` schema and
3691    /// return a `Reference` to it. Shared by property-level enums
3692    /// (`{Schema}{Prop}`) and array-item enums (`{Schema}{Prop}Item`).
3693    ///
3694    /// Resolves a name that either matches an existing same-valued
3695    /// enum (dedup) or doesn't collide with a different one.
3696    ///
3697    /// Two distinct inline enums can land on the same primary
3698    /// candidate when a parent schema has a property like
3699    /// `type` that recurs at multiple nesting levels — e.g.
3700    /// Latitude.sh's `plan_data.type = ["plans"]` (the
3701    /// JSON-API resource type) and
3702    /// `plan_data.attributes.specs.drives[].type =
3703    /// ["SSD","HDD","NVME"]` both want to become
3704    /// `PlanDataType`. We must NOT silently overwrite the
3705    /// first registration: that breaks deserialization
3706    /// because both fields end up referencing whichever
3707    /// enum was processed last.
3708    ///
3709    /// Disambiguation strategy: append the PascalCase first
3710    /// enum value (`PlanDataTypeNVME` vs `PlanDataTypePlans`)
3711    /// and, if that's also claimed with different values,
3712    /// fall back to a numeric `_2`, `_3`, … suffix.
3713    fn hoist_inline_string_enum(
3714        &mut self,
3715        schema: &Schema,
3716        enum_values: Vec<String>,
3717        primary_name: String,
3718        dependencies: &mut HashSet<String>,
3719    ) -> SchemaType {
3720        fn matches_values(existing: &AnalyzedSchema, values: &[String]) -> bool {
3721            matches!(
3722                &existing.schema_type,
3723                SchemaType::StringEnum { values: existing_values }
3724                    if existing_values == values
3725            )
3726        }
3727
3728        let mut enum_type_name = primary_name.clone();
3729        let should_insert = match self.resolved_cache.get(&enum_type_name) {
3730            None => true,
3731            Some(existing) if matches_values(existing, &enum_values) => false,
3732            Some(_) => {
3733                // Collision with different values — try a
3734                // value-suffixed name first.
3735                let suffix = enum_values
3736                    .first()
3737                    .map(|v| self.to_pascal_case(v))
3738                    .unwrap_or_else(|| "Variant".to_string());
3739                let candidate = format!("{primary_name}{suffix}");
3740
3741                let resolved = match self.resolved_cache.get(&candidate) {
3742                    None => Some((candidate.clone(), true)),
3743                    Some(existing) if matches_values(existing, &enum_values) => {
3744                        Some((candidate.clone(), false))
3745                    }
3746                    Some(_) => {
3747                        // Walk a numeric suffix until we find
3748                        // a slot that's free or matches.
3749                        let mut found = None;
3750                        for n in 2..1000 {
3751                            let numbered = format!("{candidate}_{n}");
3752                            match self.resolved_cache.get(&numbered) {
3753                                None => {
3754                                    found = Some((numbered, true));
3755                                    break;
3756                                }
3757                                Some(existing) if matches_values(existing, &enum_values) => {
3758                                    found = Some((numbered, false));
3759                                    break;
3760                                }
3761                                Some(_) => continue,
3762                            }
3763                        }
3764                        found
3765                    }
3766                };
3767
3768                let (resolved_name, insert) = resolved.unwrap_or((candidate, true));
3769                enum_type_name = resolved_name;
3770                insert
3771            }
3772        };
3773
3774        // Store the enum as a named schema if this is the
3775        // first time we've seen this exact (name, values) pair.
3776        if should_insert {
3777            self.resolved_cache.insert(
3778                enum_type_name.clone(),
3779                AnalyzedSchema {
3780                    name: enum_type_name.clone(),
3781                    original: serde_json::to_value(schema).unwrap_or(Value::Null),
3782                    schema_type: SchemaType::StringEnum {
3783                        values: enum_values,
3784                    },
3785                    dependencies: HashSet::new(),
3786                    nullable: false,
3787                    description: schema.details().description.clone(),
3788                    default: schema.details().default.clone(),
3789                },
3790            );
3791        }
3792
3793        // Return a reference to the named enum type
3794        dependencies.insert(enum_type_name.clone());
3795        SchemaType::Reference {
3796            target: enum_type_name,
3797        }
3798    }
3799
3800    fn analyze_array_schema(
3801        &mut self,
3802        schema: &Schema,
3803        parent_schema_name: &str,
3804        dependencies: &mut HashSet<String>,
3805    ) -> Result<SchemaType> {
3806        let details = schema.details();
3807
3808        // Check if items field is present
3809        if let Some(items_schema) = &details.items {
3810            // Analyze the item type
3811            let item_type = match items_schema.as_ref() {
3812                Schema::Reference { reference, .. } => {
3813                    // Array of referenced types
3814                    let target = self
3815                        .extract_schema_name(reference)
3816                        .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))?
3817                        .to_string();
3818                    dependencies.insert(target.clone());
3819                    SchemaType::Reference { target }
3820                }
3821                Schema::RecursiveRef { recursive_ref, .. } => {
3822                    // Array of recursive references
3823                    if recursive_ref == "#" {
3824                        // Self-reference to the current schema
3825                        let target = self
3826                            .find_recursive_anchor_schema()
3827                            .unwrap_or_else(|| parent_schema_name.to_string());
3828                        dependencies.insert(target.clone());
3829                        SchemaType::Reference { target }
3830                    } else {
3831                        let target = self
3832                            .extract_schema_name(recursive_ref)
3833                            .unwrap_or("RecursiveType")
3834                            .to_string();
3835                        dependencies.insert(target.clone());
3836                        SchemaType::Reference { target }
3837                    }
3838                }
3839                Schema::Typed { schema_type, .. } => {
3840                    // Array of primitive types
3841                    match schema_type {
3842                        OpenApiSchemaType::String => {
3843                            // Inline string enum in array items — hoist to a
3844                            // named enum (`{Parent}Item`) instead of collapsing
3845                            // to `Vec<String>`.
3846                            match items_schema
3847                                .details()
3848                                .string_enum_values()
3849                                .filter(|values| !values.is_empty())
3850                            {
3851                                Some(values) => self.hoist_inline_string_enum(
3852                                    items_schema,
3853                                    values,
3854                                    format!("{parent_schema_name}Item"),
3855                                    dependencies,
3856                                ),
3857                                None => SchemaType::Primitive {
3858                                    rust_type: "String".to_string(),
3859                                    serde_with: None,
3860                                },
3861                            }
3862                        }
3863                        OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
3864                            let details = items_schema.details();
3865                            let rust_type = self.get_number_rust_type(schema_type.clone(), details);
3866                            SchemaType::Primitive {
3867                                rust_type,
3868                                serde_with: None,
3869                            }
3870                        }
3871                        OpenApiSchemaType::Boolean => SchemaType::Primitive {
3872                            rust_type: "bool".to_string(),
3873                            serde_with: None,
3874                        },
3875                        OpenApiSchemaType::Object => {
3876                            // Inline object in array - create a named schema for it
3877                            let object_type_name = format!("{parent_schema_name}Item");
3878
3879                            // Analyze the object schema
3880                            let object_type =
3881                                self.analyze_object_schema(items_schema, dependencies)?;
3882
3883                            // Create an analyzed schema for the inline object
3884                            let inline_schema = AnalyzedSchema {
3885                                name: object_type_name.clone(),
3886                                original: serde_json::to_value(items_schema).unwrap_or(Value::Null),
3887                                schema_type: object_type,
3888                                dependencies: dependencies.clone(),
3889                                nullable: false,
3890                                description: items_schema.details().description.clone(),
3891                                default: None,
3892                            };
3893
3894                            // Add the inline object as a named schema
3895                            self.resolved_cache
3896                                .insert(object_type_name.clone(), inline_schema);
3897                            dependencies.insert(object_type_name.clone());
3898
3899                            // Return a reference to the named schema
3900                            SchemaType::Reference {
3901                                target: object_type_name,
3902                            }
3903                        }
3904                        OpenApiSchemaType::Array => {
3905                            // Array of arrays - recursively analyze
3906                            self.analyze_array_schema(
3907                                items_schema,
3908                                parent_schema_name,
3909                                dependencies,
3910                            )?
3911                        }
3912                        _ => SchemaType::Primitive {
3913                            rust_type: "serde_json::Value".to_string(),
3914                            serde_with: None,
3915                        },
3916                    }
3917                }
3918                Schema::OneOf { .. } | Schema::AnyOf { .. } => {
3919                    // Union types in arrays - analyze recursively
3920                    let analyzed = self.analyze_schema_value(items_schema, "ArrayItem")?;
3921
3922                    // If we got a discriminated union or union, we need to create a separate schema for it
3923                    match &analyzed.schema_type {
3924                        SchemaType::DiscriminatedUnion { .. } | SchemaType::Union { .. } => {
3925                            // Generate a unique name for the union schema based on the parent context
3926                            // Use the parent context directly to maintain consistent naming
3927                            let union_name = format!("{parent_schema_name}ItemUnion");
3928
3929                            // Create a new analyzed schema with the correct name
3930                            let mut union_schema = analyzed;
3931                            union_schema.name = union_name.clone();
3932
3933                            // Add the union as a separate schema
3934                            self.resolved_cache.insert(union_name.clone(), union_schema);
3935
3936                            // Add dependency
3937                            dependencies.insert(union_name.clone());
3938
3939                            // Return a reference to the union schema
3940                            SchemaType::Reference { target: union_name }
3941                        }
3942                        _ => analyzed.schema_type,
3943                    }
3944                }
3945                Schema::Untyped { .. } => {
3946                    // Try to infer the type
3947                    if let Some(inferred) = items_schema.inferred_type() {
3948                        match inferred {
3949                            OpenApiSchemaType::Object => {
3950                                // Inline object in array - create a named schema for it
3951                                let object_type_name = format!("{parent_schema_name}Item");
3952
3953                                // Analyze the object schema
3954                                let object_type =
3955                                    self.analyze_object_schema(items_schema, dependencies)?;
3956
3957                                // Create an analyzed schema for the inline object
3958                                let inline_schema = AnalyzedSchema {
3959                                    name: object_type_name.clone(),
3960                                    original: serde_json::to_value(items_schema)
3961                                        .unwrap_or(Value::Null),
3962                                    schema_type: object_type,
3963                                    dependencies: dependencies.clone(),
3964                                    nullable: false,
3965                                    description: items_schema.details().description.clone(),
3966                                    default: None,
3967                                };
3968
3969                                // Add the inline object as a named schema
3970                                self.resolved_cache
3971                                    .insert(object_type_name.clone(), inline_schema);
3972                                dependencies.insert(object_type_name.clone());
3973
3974                                // Return a reference to the named schema
3975                                SchemaType::Reference {
3976                                    target: object_type_name,
3977                                }
3978                            }
3979                            OpenApiSchemaType::String => {
3980                                // Typeless (OpenAPI 3.1) enum in array items —
3981                                // same hoisting as the typed-string arm.
3982                                match items_schema
3983                                    .details()
3984                                    .string_enum_values()
3985                                    .filter(|values| !values.is_empty())
3986                                {
3987                                    Some(values) => self.hoist_inline_string_enum(
3988                                        items_schema,
3989                                        values,
3990                                        format!("{parent_schema_name}Item"),
3991                                        dependencies,
3992                                    ),
3993                                    None => SchemaType::Primitive {
3994                                        rust_type: "String".to_string(),
3995                                        serde_with: None,
3996                                    },
3997                                }
3998                            }
3999                            OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
4000                                let details = items_schema.details();
4001                                let rust_type = self.get_number_rust_type(inferred, details);
4002                                SchemaType::Primitive {
4003                                    rust_type,
4004                                    serde_with: None,
4005                                }
4006                            }
4007                            OpenApiSchemaType::Boolean => SchemaType::Primitive {
4008                                rust_type: "bool".to_string(),
4009                                serde_with: None,
4010                            },
4011                            _ => SchemaType::Primitive {
4012                                rust_type: "serde_json::Value".to_string(),
4013                                serde_with: None,
4014                            },
4015                        }
4016                    } else {
4017                        SchemaType::Primitive {
4018                            rust_type: "serde_json::Value".to_string(),
4019                            serde_with: None,
4020                        }
4021                    }
4022                }
4023                _ => SchemaType::Primitive {
4024                    rust_type: "serde_json::Value".to_string(),
4025                    serde_with: None,
4026                },
4027            };
4028
4029            Ok(SchemaType::Array {
4030                item_type: Box::new(item_type),
4031            })
4032        } else {
4033            // No items specified, fall back to generic array
4034            Ok(SchemaType::Primitive {
4035                rust_type: "Vec<serde_json::Value>".to_string(),
4036                serde_with: None,
4037            })
4038        }
4039    }
4040
4041    fn get_number_rust_type(
4042        &self,
4043        schema_type: OpenApiSchemaType,
4044        details: &crate::openapi::SchemaDetails,
4045    ) -> String {
4046        // Q2.0: delegate to the TypeMapper chokepoint. The fallback for
4047        // non-numeric inputs is preserved for backwards compatibility
4048        // (callers in 2025-era code path `Integer | Number` here).
4049        let format = details.format.as_deref();
4050        match schema_type {
4051            OpenApiSchemaType::Integer => self.type_mapper.integer_format(format).rust_type,
4052            OpenApiSchemaType::Number => self.type_mapper.number_format(format).rust_type,
4053            _ => self.type_mapper.dynamic_json().rust_type,
4054        }
4055    }
4056
4057    fn analyze_anyof_union(
4058        &mut self,
4059        any_of_schemas: &[Schema],
4060        discriminator: Option<&Discriminator>,
4061        dependencies: &mut HashSet<String>,
4062        context_name: &str,
4063    ) -> Result<SchemaType> {
4064        // Drop {"type": "null"} variants. Nullability is surfaced as Option<T>
4065        // at the property level via is_nullable_pattern(); leaving the null
4066        // variant in here would produce a phantom `()` or `serde_json::Value`
4067        // type alias that the generator can't render.
4068        let filtered_owned: Vec<Schema>;
4069        let any_of_schemas: &[Schema] = if any_of_schemas
4070            .iter()
4071            .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
4072        {
4073            filtered_owned = any_of_schemas
4074                .iter()
4075                .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
4076                .cloned()
4077                .collect();
4078            if filtered_owned.is_empty() {
4079                return Ok(SchemaType::Primitive {
4080                    rust_type: "serde_json::Value".to_string(),
4081                    serde_with: None,
4082                });
4083            }
4084            if filtered_owned.len() == 1 {
4085                return self
4086                    .analyze_schema_value(&filtered_owned[0], context_name)
4087                    .map(|a| a.schema_type);
4088            }
4089            &filtered_owned
4090        } else {
4091            any_of_schemas
4092        };
4093
4094        // Pattern 2: Multiple complex types or mixed primitive/complex = flexible union
4095        let has_refs = any_of_schemas.iter().any(|s| s.is_reference());
4096        let has_objects = any_of_schemas.iter().any(|s| {
4097            matches!(s.schema_type(), Some(OpenApiSchemaType::Object))
4098                || s.inferred_type() == Some(OpenApiSchemaType::Object)
4099        });
4100        let has_arrays = any_of_schemas
4101            .iter()
4102            .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Array)));
4103
4104        // Handle mixed primitive and complex types (like string + array of objects)
4105        // Skip this pattern if all schemas are strings or const values (handle in pattern 3)
4106        let all_string_like = any_of_schemas.iter().all(|s| {
4107            matches!(s.schema_type(), Some(OpenApiSchemaType::String))
4108                || s.details().const_value.is_some()
4109        });
4110
4111        if (has_refs || has_objects || has_arrays || any_of_schemas.len() > 1) && !all_string_like {
4112            // Check if this is a discriminated union
4113            if let Some(disc) = discriminator {
4114                // This is a discriminated anyOf union, analyze it the same way as oneOf
4115                return self.analyze_oneof_union(
4116                    any_of_schemas,
4117                    Some(disc),
4118                    context_name,
4119                    dependencies,
4120                );
4121            }
4122
4123            // Auto-detect implicit discriminator from const fields across all variants
4124            if let Some(disc_field) = self.detect_discriminator_field(any_of_schemas) {
4125                return self.analyze_oneof_union(
4126                    any_of_schemas,
4127                    Some(&Discriminator {
4128                        property_name: disc_field,
4129                        mapping: None,
4130                        default_mapping: None,
4131                        extensions: crate::extensions::Extensions::default(),
4132                    }),
4133                    context_name,
4134                    dependencies,
4135                );
4136            }
4137
4138            // Create an untagged union for flexible matching
4139            let mut variants = Vec::new();
4140
4141            for schema in any_of_schemas {
4142                if let Some(ref_str) = schema.reference() {
4143                    if let Some(target) = self.extract_schema_name(ref_str) {
4144                        dependencies.insert(target.to_string());
4145                        variants.push(SchemaRef {
4146                            target: target.to_string(),
4147                            nullable: false,
4148                        });
4149                    }
4150                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object))
4151                    || schema.inferred_type() == Some(OpenApiSchemaType::Object)
4152                {
4153                    // Generate inline object type for anyOf union
4154                    let inline_index = variants.len();
4155                    let inline_type_name = self.generate_inline_type_name(schema, inline_index);
4156
4157                    // Store inline schema for later analysis and generation
4158                    self.add_inline_schema(&inline_type_name, schema, dependencies)?;
4159
4160                    variants.push(SchemaRef {
4161                        target: inline_type_name,
4162                        nullable: false,
4163                    });
4164                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Array)) {
4165                    // Handle array types in unions by creating a type alias
4166                    let array_type =
4167                        self.analyze_array_schema(schema, context_name, dependencies)?;
4168
4169                    // Create a unique name for this array type in the union
4170                    let array_type_name = if let Some(items_schema) = &schema.details().items {
4171                        if let Some(ref_str) = items_schema.reference() {
4172                            if let Some(item_type_name) = self.extract_schema_name(ref_str) {
4173                                dependencies.insert(item_type_name.to_string());
4174                                format!("{item_type_name}Array")
4175                            } else {
4176                                self.generate_context_aware_name(
4177                                    context_name,
4178                                    "Array",
4179                                    variants.len(),
4180                                    Some(schema),
4181                                )
4182                            }
4183                        } else {
4184                            self.generate_context_aware_name(
4185                                context_name,
4186                                "Array",
4187                                variants.len(),
4188                                Some(schema),
4189                            )
4190                        }
4191                    } else {
4192                        self.generate_context_aware_name(
4193                            context_name,
4194                            "Array",
4195                            variants.len(),
4196                            Some(schema),
4197                        )
4198                    };
4199
4200                    // Store the array as a type alias
4201                    self.resolved_cache.insert(
4202                        array_type_name.clone(),
4203                        AnalyzedSchema {
4204                            name: array_type_name.clone(),
4205                            original: serde_json::to_value(schema).unwrap_or(Value::Null),
4206                            schema_type: array_type,
4207                            dependencies: HashSet::new(),
4208                            nullable: false,
4209                            description: Some("Array variant in union".to_string()),
4210                            default: None,
4211                        },
4212                    );
4213
4214                    // Add array type as a dependency
4215                    dependencies.insert(array_type_name.clone());
4216
4217                    variants.push(SchemaRef {
4218                        target: array_type_name,
4219                        nullable: false,
4220                    });
4221                } else if let Some(schema_type) = schema.schema_type() {
4222                    // Q2.7: when `primitive_unions` is on (default),
4223                    // emit the Rust type directly as the variant
4224                    // target — matches `analyze_untagged_oneof_union`
4225                    // and produces a clean
4226                    //   #[serde(untagged)] pub enum Foo { String(String), Integer(i64) }
4227                    // Pre-Q2.7 / opt-out emits a type alias per
4228                    // primitive (`pub type FooString = String`) and
4229                    // references the alias in the variant — works
4230                    // but adds noise.
4231                    let primitive_unions = self
4232                        .type_mapper
4233                        .config_shape_primitive_unions()
4234                        .unwrap_or(true);
4235
4236                    if primitive_unions {
4237                        let mapped = self.type_mapper.map(schema_type.clone(), schema.details());
4238                        variants.push(SchemaRef {
4239                            target: mapped.rust_type,
4240                            nullable: false,
4241                        });
4242                    } else {
4243                        let inline_index = variants.len();
4244                        let inline_type_name = match schema_type {
4245                            OpenApiSchemaType::String => {
4246                                if inline_index == 0 {
4247                                    format!("{context_name}String")
4248                                } else {
4249                                    format!("{context_name}StringVariant{inline_index}")
4250                                }
4251                            }
4252                            OpenApiSchemaType::Number => {
4253                                if inline_index == 0 {
4254                                    format!("{context_name}Number")
4255                                } else {
4256                                    format!("{context_name}NumberVariant{inline_index}")
4257                                }
4258                            }
4259                            OpenApiSchemaType::Integer => {
4260                                if inline_index == 0 {
4261                                    format!("{context_name}Integer")
4262                                } else {
4263                                    format!("{context_name}IntegerVariant{inline_index}")
4264                                }
4265                            }
4266                            OpenApiSchemaType::Boolean => {
4267                                if inline_index == 0 {
4268                                    format!("{context_name}Boolean")
4269                                } else {
4270                                    format!("{context_name}BooleanVariant{inline_index}")
4271                                }
4272                            }
4273                            _ => format!("{context_name}Variant{inline_index}"),
4274                        };
4275
4276                        let rust_type =
4277                            self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
4278
4279                        self.resolved_cache.insert(
4280                            inline_type_name.clone(),
4281                            AnalyzedSchema {
4282                                name: inline_type_name.clone(),
4283                                original: serde_json::to_value(schema).unwrap_or(Value::Null),
4284                                schema_type: SchemaType::Primitive {
4285                                    rust_type,
4286                                    serde_with: None,
4287                                },
4288                                dependencies: HashSet::new(),
4289                                nullable: false,
4290                                description: schema.details().description.clone(),
4291                                default: None,
4292                            },
4293                        );
4294
4295                        dependencies.insert(inline_type_name.clone());
4296
4297                        variants.push(SchemaRef {
4298                            target: inline_type_name,
4299                            nullable: false,
4300                        });
4301                    }
4302                }
4303            }
4304
4305            if !variants.is_empty() {
4306                return Ok(SchemaType::Union { variants });
4307            }
4308        }
4309
4310        // Pattern 3: String enum pattern (mix of "type": "string" and const values)
4311        let all_strings = any_of_schemas.iter().all(|schema| {
4312            matches!(schema.schema_type(), Some(OpenApiSchemaType::String))
4313                || schema.details().const_value.is_some()
4314        });
4315
4316        if all_strings {
4317            // Collect all constant values as enum variants
4318            let mut enum_values = Vec::new();
4319            let mut has_open_string = false;
4320
4321            for schema in any_of_schemas {
4322                if let Some(const_val) = &schema.details().const_value {
4323                    if let Some(const_str) = const_val.as_str() {
4324                        enum_values.push(const_str.to_string());
4325                    }
4326                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::String)) {
4327                    has_open_string = true;
4328                }
4329            }
4330
4331            if !enum_values.is_empty() {
4332                if has_open_string {
4333                    // Has both constants and open string - create an extensible enum
4334                    // This generates an enum with known variants plus a Custom(String) variant
4335                    return Ok(SchemaType::ExtensibleEnum {
4336                        known_values: enum_values,
4337                    });
4338                } else {
4339                    // All constants - create string enum
4340                    return Ok(SchemaType::StringEnum {
4341                        values: enum_values,
4342                    });
4343                }
4344            }
4345        }
4346
4347        // Pattern 4: Mixed primitives = fall back to serde_json::Value
4348        Ok(SchemaType::Primitive {
4349            rust_type: "serde_json::Value".to_string(),
4350            serde_with: None,
4351        })
4352    }
4353
4354    /// Find the schema with $recursiveAnchor: true for resolving $recursiveRef: "#"
4355    fn find_recursive_anchor_schema(&self) -> Option<String> {
4356        // Search through all schemas to find one with $recursiveAnchor: true
4357        for (schema_name, schema) in &self.schemas {
4358            let details = schema.details();
4359            if details.recursive_anchor == Some(true) {
4360                return Some(schema_name.clone());
4361            }
4362        }
4363
4364        // If no schema has $recursiveAnchor: true, this might be an older spec
4365        // In that case, $recursiveRef: "#" typically refers to the root schema
4366        // For now, return None to indicate we couldn't resolve it
4367        None
4368    }
4369
4370    /// Detect if a schema should use serde_json::Value for dynamic JSON
4371    /// Based on structural patterns identified in real-world APIs
4372    fn should_use_dynamic_json(&self, schema: &Schema) -> bool {
4373        // Pattern 1: anyOf with [object, null] where object has no properties
4374        if let Schema::AnyOf { any_of, .. } = schema {
4375            if any_of.len() == 2 {
4376                let has_null = any_of
4377                    .iter()
4378                    .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)));
4379                let has_empty_object = any_of.iter().any(|s| self.is_dynamic_object_pattern(s));
4380
4381                if has_null && has_empty_object {
4382                    return true;
4383                }
4384            }
4385        }
4386
4387        // Pattern 2: Direct empty object pattern
4388        self.is_dynamic_object_pattern(schema)
4389    }
4390
4391    /// Check if a schema represents a dynamic object pattern
4392    fn is_dynamic_object_pattern(&self, schema: &Schema) -> bool {
4393        // Must be object type or untyped with object inference
4394        let is_object = match schema.schema_type() {
4395            Some(OpenApiSchemaType::Object) => true,
4396            None => schema.inferred_type() == Some(OpenApiSchemaType::Object),
4397            _ => false,
4398        };
4399
4400        if !is_object {
4401            return false;
4402        }
4403
4404        let details = schema.details();
4405
4406        // If it has explicit additionalProperties, it should remain as a typed object
4407        // that will be generated as BTreeMap<String, serde_json::Value> or similar
4408        if self.has_explicit_additional_properties(schema) {
4409            return false;
4410        }
4411
4412        // Pattern 1: Object with no properties at all (and no additionalProperties)
4413        let no_properties = details
4414            .properties
4415            .as_ref()
4416            .map(|props| props.is_empty())
4417            .unwrap_or(true);
4418
4419        if no_properties {
4420            // Check for constraints that would make this a structured type.
4421            // After J5–J8, these are typed fields rather than `extra` lookups.
4422            let has_structural_constraints = details
4423                .required
4424                .as_ref()
4425                .map(|req| req.iter().any(|r| r != "type"))
4426                .unwrap_or(false)
4427                || details.pattern_properties.is_some()
4428                || details.property_names.is_some()
4429                || details.min_properties.is_some()
4430                || details.max_properties.is_some()
4431                || details.dependent_required.is_some()
4432                || details.dependent_schemas.is_some()
4433                || details.if_schema.is_some()
4434                || details.then_schema.is_some()
4435                || details.else_schema.is_some();
4436
4437            return !has_structural_constraints;
4438        }
4439
4440        false
4441    }
4442
4443    /// Check if this is an object that explicitly allows arbitrary additional properties
4444    fn has_explicit_additional_properties(&self, schema: &Schema) -> bool {
4445        let details = schema.details();
4446
4447        // Check if additionalProperties is explicitly set to true or a schema
4448        matches!(
4449            &details.additional_properties,
4450            Some(crate::openapi::AdditionalProperties::Boolean(true))
4451                | Some(crate::openapi::AdditionalProperties::Schema(_))
4452        )
4453    }
4454
4455    /// Analyze OpenAPI operations to extract request/response schemas
4456    fn analyze_operations(&mut self, analysis: &mut SchemaAnalysis) -> Result<()> {
4457        let spec: crate::openapi::OpenApiSpec = serde_json::from_value(self.openapi_spec.clone())
4458            .map_err(GeneratorError::ParseError)?;
4459        // Operation IDs are emitted into one Rust module, so collision
4460        // detection spans paths and webhooks. Index their canonical Rust type
4461        // names once instead of re-canonicalizing every previously analyzed
4462        // operation for every new endpoint.
4463        let mut canonical_operation_ids = HashSet::new();
4464
4465        if let Some(paths) = &spec.paths {
4466            for (path, path_item) in paths {
4467                // H11: Path Item may be a $ref to components/pathItems. Resolve here.
4468                let resolved = self.resolve_path_item(path_item, &spec)?;
4469                let pi: &crate::openapi::PathItem = resolved.as_ref().unwrap_or(path_item);
4470                self.ingest_path_item_operations(path, pi, analysis, &mut canonical_operation_ids)?;
4471            }
4472        }
4473        // T4: walk webhooks the same way as paths. Per OAS 3.1+, webhooks are
4474        // server→consumer callbacks: their request bodies describe payloads
4475        // the *server* sends *to* the consumer. We currently emit them as
4476        // ordinary operations so their request/response types land in the
4477        // generated client; a future bead may add a typed Webhook enum and
4478        // dispatcher.
4479        if let Some(webhooks) = &spec.webhooks {
4480            for (name, path_item) in webhooks {
4481                let synthetic_path = format!("/__webhook__/{name}");
4482                self.ingest_path_item_operations(
4483                    &synthetic_path,
4484                    path_item,
4485                    analysis,
4486                    &mut canonical_operation_ids,
4487                )?;
4488            }
4489        }
4490        Ok(())
4491    }
4492
4493    /// H11: Resolve a Path Item's `$ref` (3.1+ allows them) against
4494    /// `components/pathItems`. Returns Some(resolved) when a ref was followed,
4495    /// or None when the input is already inline.
4496    fn resolve_path_item(
4497        &self,
4498        path_item: &crate::openapi::PathItem,
4499        spec: &crate::openapi::OpenApiSpec,
4500    ) -> Result<Option<crate::openapi::PathItem>> {
4501        let Some(reference) = &path_item.reference else {
4502            return Ok(None);
4503        };
4504        let target_name = reference
4505            .strip_prefix("#/components/pathItems/")
4506            .ok_or_else(|| {
4507                GeneratorError::UnresolvedReference(format!(
4508                    "Path Item $ref must point at #/components/pathItems/{{name}}, got {reference}"
4509                ))
4510            })?;
4511        let pi = spec
4512            .components
4513            .as_ref()
4514            .and_then(|c| c.path_items.as_ref())
4515            .and_then(|map| map.get(target_name))
4516            .ok_or_else(|| {
4517                GeneratorError::UnresolvedReference(format!(
4518                    "Path Item ref {reference} not found in components/pathItems"
4519                ))
4520            })?;
4521        Ok(Some(pi.clone()))
4522    }
4523
4524    fn ingest_path_item_operations(
4525        &mut self,
4526        path: &str,
4527        path_item: &crate::openapi::PathItem,
4528        analysis: &mut SchemaAnalysis,
4529        canonical_operation_ids: &mut HashSet<String>,
4530    ) -> Result<()> {
4531        for (method, operation) in path_item.operations() {
4532            // Generate operation ID if missing.
4533            let raw_operation_id = operation
4534                .operation_id
4535                .clone()
4536                .unwrap_or_else(|| Self::generate_operation_id(method, path));
4537
4538            // T6: detect operationId collisions. Per the OAS spec these MUST
4539            // be unique, but real-world specs (arcade, cal-com, telnyx,
4540            // val-town, …) frequently aren't. Auto-disambiguate by suffixing
4541            // with the method, then a counter, and warn.
4542            //
4543            // The collision key is the PascalCased form so that case-only
4544            // differences (telnyx has `getMdrUsageReports` AND
4545            // `GetMdrUsageReports`) collide too — otherwise codegen would
4546            // produce two `GetMdrUsageReportsApiError` enums in the same
4547            // module.
4548            let operation_id = if canonical_operation_ids
4549                .contains(&Self::canonical_operation_id(&raw_operation_id))
4550            {
4551                let method_lower = method.to_lowercase();
4552                let mut candidate = format!("{}_{}", raw_operation_id, method_lower);
4553                let mut suffix = 2;
4554                while canonical_operation_ids.contains(&Self::canonical_operation_id(&candidate)) {
4555                    candidate = format!("{}_{}_{}", raw_operation_id, method_lower, suffix);
4556                    suffix += 1;
4557                }
4558                eprintln!(
4559                    "⚠️  duplicate operationId `{}` at `{} {}` — disambiguated to `{}`",
4560                    raw_operation_id, method, path, candidate
4561                );
4562                candidate
4563            } else {
4564                raw_operation_id.clone()
4565            };
4566
4567            let (op_info, responses) = self.analyze_single_operation(
4568                &operation_id,
4569                method,
4570                path,
4571                operation,
4572                path_item.parameters.as_ref(),
4573                analysis,
4574            )?;
4575            analysis
4576                .operation_id_aliases
4577                .entry(raw_operation_id)
4578                .or_default()
4579                .push(operation_id.clone());
4580            canonical_operation_ids.insert(Self::canonical_operation_id(&operation_id));
4581            analysis
4582                .operation_responses
4583                .insert(operation_id.clone(), responses);
4584            analysis.operations.insert(operation_id, op_info);
4585        }
4586        Ok(())
4587    }
4588
4589    fn canonical_operation_id(operation_id: &str) -> String {
4590        use heck::ToPascalCase;
4591        operation_id.replace('.', "_").to_pascal_case()
4592    }
4593
4594    /// Generate an operation ID from method and path when not provided
4595    /// Converts paths like "/v0/servers/{serverId}" + "get" to "getV0ServersServerId"
4596    fn generate_operation_id(method: &str, path: &str) -> String {
4597        // Start with the HTTP method in lowercase
4598        let mut operation_id = method.to_lowercase();
4599
4600        // Process the path: remove leading slash, split by /, convert to camelCase
4601        let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
4602
4603        for part in path_parts {
4604            if part.is_empty() {
4605                continue;
4606            }
4607
4608            // Handle path parameters: {serverId} -> ServerId
4609            let cleaned_part = if part.starts_with('{') && part.ends_with('}') {
4610                &part[1..part.len() - 1]
4611            } else {
4612                part
4613            };
4614
4615            // Convert to PascalCase and append
4616            let pascal_case_part = cleaned_part
4617                .split(&['-', '_'][..])
4618                .map(|s| {
4619                    let mut chars = s.chars();
4620                    match chars.next() {
4621                        None => String::new(),
4622                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4623                    }
4624                })
4625                .collect::<String>();
4626
4627            operation_id.push_str(&pascal_case_part);
4628        }
4629
4630        operation_id
4631    }
4632
4633    /// Analyze a single OpenAPI operation
4634    fn analyze_single_operation(
4635        &mut self,
4636        operation_id: &str,
4637        method: &str,
4638        path: &str,
4639        operation: &crate::openapi::Operation,
4640        path_item_parameters: Option<&Vec<crate::openapi::Parameter>>,
4641        _analysis: &mut SchemaAnalysis,
4642    ) -> Result<(OperationInfo, BTreeMap<String, OperationResponse>)> {
4643        let raw_path_item = self
4644            .openapi_spec
4645            .get("paths")
4646            .and_then(|paths| paths.get(path))
4647            .cloned();
4648        let raw_operation = raw_path_item
4649            .as_ref()
4650            .and_then(|path_item| path_item.get(method.to_ascii_lowercase()))
4651            .cloned();
4652        let request_body = operation
4653            .request_body
4654            .as_ref()
4655            .map(|request_body| self.resolve_request_body(request_body))
4656            .transpose()?;
4657        let mut op_info = OperationInfo {
4658            operation_id: operation_id.to_string(),
4659            method: method.to_uppercase(),
4660            path: normalize_operation_path(path),
4661            summary: operation.summary.clone(),
4662            description: operation.description.clone(),
4663            request_body: None,
4664            // Per OAS 3.x §"Request Body Object", `required` defaults to false.
4665            request_body_required: request_body
4666                .as_ref()
4667                .and_then(|rb| rb.required)
4668                .unwrap_or(false),
4669            response_schemas: BTreeMap::new(),
4670            parameters: Vec::new(),
4671            supports_streaming: false, // Will be determined by StreamingConfig, not spec
4672            stream_parameter: None,    // Will be determined by StreamingConfig, not spec
4673            tags: operation.tags.clone().unwrap_or_default(),
4674        };
4675        let mut operation_responses = BTreeMap::new();
4676
4677        // Extract request body schema with content-type awareness
4678        if let Some(request_body) = &request_body {
4679            use crate::openapi::{
4680                is_binary_media_type, is_form_urlencoded_media_type, is_json_media_type,
4681                media_type_essence,
4682            };
4683            if let Some((content_type, maybe_schema)) = request_body.best_content() {
4684                op_info.request_body = if is_json_media_type(content_type) {
4685                    match maybe_schema {
4686                        Some(s) => {
4687                            let validation_schema = self
4688                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
4689                                .unwrap_or(
4690                                    serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
4691                                );
4692                            Some(
4693                                self.resolve_or_inline_schema(s, operation_id, "Request")
4694                                    .map(|name| RequestBodyContent::Json {
4695                                        schema_name: name,
4696                                        media_type: content_type.to_string(),
4697                                        validation_schema,
4698                                    })?,
4699                            )
4700                        }
4701                        None => Some(RequestBodyContent::SchemaLess {
4702                            media_type: content_type.to_string(),
4703                        }),
4704                    }
4705                } else if is_form_urlencoded_media_type(content_type) {
4706                    match maybe_schema {
4707                        Some(s) => {
4708                            let validation_schema = self
4709                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
4710                                .unwrap_or(
4711                                    serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
4712                                );
4713                            Some(
4714                                self.resolve_or_inline_schema(s, operation_id, "Request")
4715                                    .map(|name| RequestBodyContent::FormUrlEncoded {
4716                                        schema_name: name,
4717                                        media_type: content_type.to_string(),
4718                                        validation_schema,
4719                                    })?,
4720                            )
4721                        }
4722                        None => Some(RequestBodyContent::SchemaLess {
4723                            media_type: content_type.to_string(),
4724                        }),
4725                    }
4726                } else if media_type_essence(content_type)
4727                    .eq_ignore_ascii_case("multipart/form-data")
4728                {
4729                    match maybe_schema {
4730                        Some(schema) => {
4731                            let validation_schema = self
4732                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
4733                                .unwrap_or(
4734                                    serde_json::to_value(schema)
4735                                        .map_err(GeneratorError::ParseError)?,
4736                                );
4737                            Some(
4738                                self.resolve_or_inline_schema(schema, operation_id, "Request")
4739                                    .map(|schema_name| RequestBodyContent::Multipart {
4740                                        schema_name,
4741                                        media_type: content_type.to_string(),
4742                                        validation_schema,
4743                                    })?,
4744                            )
4745                        }
4746                        None => Some(RequestBodyContent::SchemaLess {
4747                            media_type: content_type.to_string(),
4748                        }),
4749                    }
4750                } else if is_binary_media_type(content_type, maybe_schema) {
4751                    if media_type_essence(content_type)
4752                        .eq_ignore_ascii_case("application/octet-stream")
4753                    {
4754                        Some(RequestBodyContent::OctetStream {
4755                            media_type: content_type.to_string(),
4756                        })
4757                    } else {
4758                        Some(RequestBodyContent::Binary {
4759                            media_type: content_type.to_string(),
4760                        })
4761                    }
4762                } else if crate::openapi::is_text_media_type(content_type) {
4763                    // Any character-data media type (text/plain, text/xml,
4764                    // application/xml, +xml suffixed) is buffered and handed
4765                    // to the handler as a lossless UTF-8 String; the server
4766                    // never parses the payload.
4767                    Some(RequestBodyContent::TextPlain {
4768                        media_type: content_type.to_string(),
4769                    })
4770                } else {
4771                    None
4772                };
4773            }
4774            if op_info.request_body.is_none() {
4775                let mut media_types = request_body
4776                    .content
4777                    .as_ref()
4778                    .map(|content| content.keys().cloned().collect::<Vec<_>>())
4779                    .unwrap_or_default();
4780                media_types.sort();
4781                if !media_types.is_empty() {
4782                    op_info.request_body = Some(RequestBodyContent::Unsupported { media_types });
4783                }
4784            }
4785        }
4786
4787        // Extract response schemas
4788        if let Some(responses) = &operation.responses {
4789            for (status_code, response) in responses {
4790                let response = self.resolve_response(response)?;
4791                // T15: SSE auto-detection. If any response declares
4792                // `text/event-stream`, mark the operation as streaming. The
4793                // user can still override via config; here we lift the spec
4794                // signal so a `stream: true` parameter and an event-stream
4795                // content type produce a streaming variant by default.
4796                let supports_streaming = response.content.as_ref().is_some_and(|content| {
4797                    content
4798                        .keys()
4799                        .any(|ct| crate::openapi::is_event_stream_media_type(ct))
4800                });
4801                if supports_streaming {
4802                    op_info.supports_streaming = true;
4803                }
4804
4805                let mut response_info = OperationResponse {
4806                    supports_streaming,
4807                    has_content: response
4808                        .content
4809                        .as_ref()
4810                        .is_some_and(|content| !content.is_empty()),
4811                    ..Default::default()
4812                };
4813                if let Some((media_type, schema)) = response.json_content() {
4814                    if let Some(schema_ref) = schema.reference() {
4815                        // Named schema reference
4816                        if let Some(schema_name) = self.extract_schema_name(schema_ref) {
4817                            op_info
4818                                .response_schemas
4819                                .insert(status_code.clone(), schema_name.to_string());
4820                            response_info.schema_name = Some(schema_name.to_string());
4821                            response_info.media_type = Some(media_type.to_string());
4822                            response_info.body = Some(OperationResponseBody::Json {
4823                                schema_name: schema_name.to_string(),
4824                                media_type: media_type.to_string(),
4825                            });
4826                        }
4827                    } else {
4828                        // Inline schema - generate a synthetic type name and analyze it
4829                        let synthetic_name =
4830                            self.generate_inline_response_type_name(operation_id, status_code);
4831
4832                        // Use the existing inline schema infrastructure
4833                        let mut deps = HashSet::new();
4834                        self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
4835
4836                        op_info
4837                            .response_schemas
4838                            .insert(status_code.clone(), synthetic_name.clone());
4839                        response_info.body = Some(OperationResponseBody::Json {
4840                            schema_name: synthetic_name.clone(),
4841                            media_type: media_type.to_string(),
4842                        });
4843                        response_info.schema_name = Some(synthetic_name);
4844                        response_info.media_type = Some(media_type.to_string());
4845                    }
4846                }
4847                if response_info.body.is_none()
4848                    && let Some(content) = response.content.as_ref()
4849                {
4850                    let selected = content
4851                        .iter()
4852                        .find(|(media_type, media)| {
4853                            matches!(
4854                                crate::openapi::classify_response_media_type(
4855                                    media_type,
4856                                    media.schema.as_ref()
4857                                ),
4858                                crate::openapi::ResponseMediaKind::Text
4859                            )
4860                        })
4861                        .or_else(|| {
4862                            content.iter().find(|(media_type, media)| {
4863                                matches!(
4864                                    crate::openapi::classify_response_media_type(
4865                                        media_type,
4866                                        media.schema.as_ref()
4867                                    ),
4868                                    crate::openapi::ResponseMediaKind::Binary
4869                                ) && !crate::openapi::is_wildcard_media_type(media_type)
4870                            })
4871                        })
4872                        .or_else(|| {
4873                            content.iter().find(|(media_type, media)| {
4874                                matches!(
4875                                    crate::openapi::classify_response_media_type(
4876                                        media_type,
4877                                        media.schema.as_ref()
4878                                    ),
4879                                    crate::openapi::ResponseMediaKind::Binary
4880                                )
4881                            })
4882                        });
4883                    if let Some((media_type, media)) = selected {
4884                        response_info.body = match crate::openapi::classify_response_media_type(
4885                            media_type,
4886                            media.schema.as_ref(),
4887                        ) {
4888                            crate::openapi::ResponseMediaKind::Text => {
4889                                Some(OperationResponseBody::Text {
4890                                    media_type: media_type.clone(),
4891                                })
4892                            }
4893                            crate::openapi::ResponseMediaKind::Binary => {
4894                                Some(OperationResponseBody::Binary {
4895                                    media_type: media_type.clone(),
4896                                    wildcard: crate::openapi::is_wildcard_media_type(media_type),
4897                                })
4898                            }
4899                            _ => None,
4900                        };
4901                    }
4902                }
4903                response_info.unsupported_media_types = response
4904                    .content
4905                    .as_ref()
4906                    .into_iter()
4907                    .flat_map(|content| content.iter())
4908                    .filter(|(media_type, content)| {
4909                        match crate::openapi::classify_response_media_type(
4910                            media_type,
4911                            content.schema.as_ref(),
4912                        ) {
4913                            crate::openapi::ResponseMediaKind::Json => content.schema.is_none(),
4914                            crate::openapi::ResponseMediaKind::Unsupported => true,
4915                            crate::openapi::ResponseMediaKind::EventStream
4916                            | crate::openapi::ResponseMediaKind::Text
4917                            | crate::openapi::ResponseMediaKind::Binary => false,
4918                        }
4919                    })
4920                    .map(|(media_type, _)| media_type.clone())
4921                    .collect();
4922                operation_responses.insert(status_code.clone(), response_info);
4923            }
4924        }
4925
4926        // T15: detect a `stream` boolean parameter on the operation; pair it
4927        // with the SSE response signal above to populate stream_parameter.
4928        if op_info.supports_streaming
4929            && let Some(parameters) = &operation.parameters
4930        {
4931            for param in parameters {
4932                if let Some(name) = param.name.as_deref() {
4933                    if name.eq_ignore_ascii_case("stream") {
4934                        op_info.stream_parameter = Some(name.to_string());
4935                        break;
4936                    }
4937                }
4938            }
4939        }
4940
4941        // Extract parameters (operation-level first, then merge path-item-level)
4942        if let Some(parameters) = &operation.parameters {
4943            for (index, param) in parameters.iter().enumerate() {
4944                // into_owned: analyze_parameter needs `&mut self` (it may
4945                // register an inline object schema for form-exploded query
4946                // params), which can't coexist with the Cow's `&self` borrow.
4947                let resolved = self.resolve_parameter(param).into_owned();
4948                let validation_schema = raw_operation
4949                    .as_ref()
4950                    .and_then(|operation| operation.get("parameters"))
4951                    .and_then(Value::as_array)
4952                    .and_then(|parameters| parameters.get(index))
4953                    .and_then(|parameter| self.raw_parameter_schema(parameter));
4954                if let Some(param_info) =
4955                    self.analyze_parameter(&resolved, operation_id, validation_schema)?
4956                {
4957                    op_info.parameters.push(param_info);
4958                }
4959            }
4960        }
4961
4962        // Merge path-item-level parameters (operation params take precedence per OpenAPI spec)
4963        if let Some(path_params) = path_item_parameters {
4964            let existing_keys: std::collections::HashSet<(String, String)> = op_info
4965                .parameters
4966                .iter()
4967                .map(|p| (p.name.clone(), p.location.clone()))
4968                .collect();
4969            for (index, param) in path_params.iter().enumerate() {
4970                let resolved = self.resolve_parameter(param).into_owned();
4971                let validation_schema = raw_path_item
4972                    .as_ref()
4973                    .and_then(|path_item| path_item.get("parameters"))
4974                    .and_then(Value::as_array)
4975                    .and_then(|parameters| parameters.get(index))
4976                    .and_then(|parameter| self.raw_parameter_schema(parameter));
4977                if let Some(param_info) =
4978                    self.analyze_parameter(&resolved, operation_id, validation_schema)?
4979                {
4980                    if !existing_keys
4981                        .contains(&(param_info.name.clone(), param_info.location.clone()))
4982                    {
4983                        op_info.parameters.push(param_info);
4984                    }
4985                }
4986            }
4987        }
4988
4989        // Synthesize path parameters that are referenced via `{var}` in the
4990        // path template but not declared as parameters in the spec.
4991        // langsmith/knocklabs/cloudflare hit this — `/repos/{owner}/{repo}/...`
4992        // declares `repo` but not `owner`. Without this, codegen emits
4993        // `format!("/repos/{owner}/...", repo)` and `owner` is undefined
4994        // (E0425). We synthesize each missing variable as a required
4995        // `String` path parameter.
4996        let mut declared_path_names: std::collections::HashSet<String> = op_info
4997            .parameters
4998            .iter()
4999            .filter(|p| p.location == "path")
5000            .map(|p| p.name.clone())
5001            .collect();
5002        let bytes = path.as_bytes().iter();
5003        let mut current = String::new();
5004        let mut in_brace = false;
5005        let mut synthesized: Vec<String> = Vec::new();
5006        for b in bytes {
5007            match *b {
5008                b'{' => {
5009                    in_brace = true;
5010                    current.clear();
5011                }
5012                b'}' if in_brace => {
5013                    in_brace = false;
5014                    if !current.is_empty() && !declared_path_names.contains(&current) {
5015                        synthesized.push(current.clone());
5016                        declared_path_names.insert(current.clone());
5017                    }
5018                }
5019                _ if in_brace => current.push(*b as char),
5020                _ => {}
5021            }
5022        }
5023        for name in synthesized {
5024            eprintln!(
5025                "⚠️  path `{}` references `{{{}}}` but the spec doesn't declare it as a parameter — synthesizing as required String",
5026                path, name
5027            );
5028            op_info.parameters.push(ParameterInfo {
5029                name,
5030                location: "path".to_string(),
5031                required: true,
5032                schema_ref: None,
5033                rust_type: "String".to_string(),
5034                description: None,
5035                enum_values: None,
5036                enum_varnames: None,
5037                rust_ident: None,
5038                query_serialization: None,
5039                validation_schema: None,
5040            });
5041        }
5042
5043        // Disambiguate Rust idents across the operation. Real-world specs
5044        // sometimes use both `kebab-case` and `snake_case` for closely-related
5045        // filter parameters (vercel: `exclude_ids` + `exclude-ids`), or
5046        // operator-suffixed forms (twilio: `StartTime`, `StartTime<`,
5047        // `StartTime>`). Without disambiguation those parameters share a
5048        // single binding and the generated body fails E0382 (use of moved
5049        // value) or E0415 (binding declared twice).
5050        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
5051        for p in op_info.parameters.iter_mut() {
5052            let raw = base_param_ident(&p.name);
5053            let mut chosen = raw.clone();
5054            let mut suffix = 2;
5055            while !used.insert(chosen.clone()) {
5056                chosen = format!("{raw}_{suffix}");
5057                suffix += 1;
5058            }
5059            p.rust_ident = Some(chosen);
5060        }
5061
5062        Ok((op_info, operation_responses))
5063    }
5064
5065    /// Resolve a local reusable Request Body Object through its JSON Pointer.
5066    fn resolve_request_body(
5067        &self,
5068        request_body: &crate::openapi::RequestBody,
5069    ) -> Result<crate::openapi::RequestBody> {
5070        let mut current = request_body.clone();
5071        let mut visited = HashSet::new();
5072        while let Some(reference) = current.reference.clone() {
5073            if !visited.insert(reference.clone()) {
5074                return Err(GeneratorError::CircularDependency(format!(
5075                    "request body reference {reference}"
5076                )));
5077            }
5078
5079            let pointer = reference.strip_prefix('#').ok_or_else(|| {
5080                GeneratorError::UnresolvedReference(format!(
5081                    "external request body reference `{reference}` is not supported"
5082                ))
5083            })?;
5084            if !pointer.is_empty() && !pointer.starts_with('/') {
5085                return Err(GeneratorError::UnresolvedReference(format!(
5086                    "request body reference `{reference}` is not a local JSON Pointer"
5087                )));
5088            }
5089            let value = self.openapi_spec.pointer(pointer).ok_or_else(|| {
5090                GeneratorError::UnresolvedReference(format!(
5091                    "request body reference `{reference}` does not exist"
5092                ))
5093            })?;
5094            let object = value.as_object().ok_or_else(|| {
5095                GeneratorError::InvalidSchema(format!(
5096                    "request body reference `{reference}` must target an object"
5097                ))
5098            })?;
5099            if !["$ref", "description", "required", "content"]
5100                .iter()
5101                .any(|field| object.contains_key(*field))
5102            {
5103                return Err(GeneratorError::InvalidSchema(format!(
5104                    "request body reference `{reference}` does not target a structurally compatible OpenAPI Request Body Object"
5105                )));
5106            }
5107            current = serde_json::from_value(value.clone()).map_err(|error| {
5108                GeneratorError::InvalidSchema(format!(
5109                    "request body reference `{reference}` is not a valid OpenAPI Request Body Object: {error}"
5110                ))
5111            })?;
5112        }
5113        Ok(current)
5114    }
5115
5116    /// Resolve a local reusable Response Object through its JSON Pointer.
5117    ///
5118    /// Real-world documents occasionally store a structurally valid Response
5119    /// Object under the wrong Components map. Resolving the pointer itself
5120    /// preserves compatibility with those documents while still validating
5121    /// that the target can be interpreted as a Response Object.
5122    fn resolve_response(
5123        &self,
5124        response: &crate::openapi::Response,
5125    ) -> Result<crate::openapi::Response> {
5126        let mut current = response.clone();
5127        let mut visited = HashSet::new();
5128        while let Some(reference) = current.reference.clone() {
5129            if !visited.insert(reference.clone()) {
5130                return Err(GeneratorError::CircularDependency(format!(
5131                    "response reference {reference}"
5132                )));
5133            }
5134
5135            let pointer = reference.strip_prefix('#').ok_or_else(|| {
5136                GeneratorError::UnresolvedReference(format!(
5137                    "external response reference `{reference}` is not supported"
5138                ))
5139            })?;
5140            if !pointer.is_empty() && !pointer.starts_with('/') {
5141                return Err(GeneratorError::UnresolvedReference(format!(
5142                    "response reference `{reference}` is not a local JSON Pointer"
5143                )));
5144            }
5145            let value = self.openapi_spec.pointer(pointer).ok_or_else(|| {
5146                GeneratorError::UnresolvedReference(format!(
5147                    "response reference `{reference}` does not exist"
5148                ))
5149            })?;
5150            let object = value.as_object().ok_or_else(|| {
5151                GeneratorError::InvalidSchema(format!(
5152                    "response reference `{reference}` must target an object"
5153                ))
5154            })?;
5155            if !["$ref", "description", "headers", "content", "links"]
5156                .iter()
5157                .any(|field| object.contains_key(*field))
5158            {
5159                return Err(GeneratorError::InvalidSchema(format!(
5160                    "response reference `{reference}` does not target a structurally compatible OpenAPI Response Object"
5161                )));
5162            }
5163            current = serde_json::from_value(value.clone()).map_err(|error| {
5164                GeneratorError::InvalidSchema(format!(
5165                    "response reference `{reference}` is not a valid OpenAPI Response Object: {error}"
5166                ))
5167            })?;
5168        }
5169        Ok(current)
5170    }
5171
5172    /// Generate a type name for an inline response schema.
5173    ///
5174    /// 200 (the canonical success status) keeps the unsuffixed `{Op}Response`
5175    /// name so simple specs and existing snapshots are unchanged. Every other
5176    /// status code is disambiguated by suffix so that multi-response operations
5177    /// (e.g. 200 + 400) don't collide in the schema registry — see issue #8.
5178    fn generate_inline_response_type_name(&self, operation_id: &str, status_code: &str) -> String {
5179        use heck::ToPascalCase;
5180        let base_name = operation_id.replace('.', "_").to_pascal_case();
5181        let suffix = Self::status_code_suffix(status_code);
5182        format!("{}Response{}", base_name, suffix)
5183    }
5184
5185    /// Map an OpenAPI status code key to a suffix for generated type names.
5186    ///
5187    /// "200" → "" (unchanged, the dominant case)
5188    /// "201", "400", "404" → "201", "400", "404"
5189    /// "default" → "Default"
5190    /// "4XX" / "4xx" → "4xx" (lowercased range form)
5191    fn status_code_suffix(status_code: &str) -> String {
5192        match status_code {
5193            "" | "200" => String::new(),
5194            "default" | "Default" => "Default".to_string(),
5195            other if other.chars().all(|c| c.is_ascii_digit()) => other.to_string(),
5196            other => other.to_ascii_lowercase(),
5197        }
5198    }
5199
5200    /// Generate a type name for an inline request body schema
5201    fn generate_inline_request_type_name(&self, operation_id: &str) -> String {
5202        use heck::ToPascalCase;
5203        // Convert operation_id to PascalCase and append Request
5204        // e.g., "session.prompt" -> "SessionPromptRequest"
5205        // e.g., "pty.create" -> "PtyCreateRequest"
5206        let base_name = operation_id.replace('.', "_").to_pascal_case();
5207        format!("{}Request", base_name)
5208    }
5209
5210    /// Resolve a schema reference to a name, or inline it with a synthetic name.
5211    /// `suffix` controls the generated name (e.g. "Request" or "Response").
5212    fn resolve_or_inline_schema(
5213        &mut self,
5214        schema: &crate::openapi::Schema,
5215        operation_id: &str,
5216        suffix: &str,
5217    ) -> Result<String> {
5218        if let Some(schema_ref) = schema.reference()
5219            && let Some(schema_name) = self.extract_schema_name(schema_ref)
5220        {
5221            return Ok(schema_name.to_string());
5222        }
5223        // Inline schema - generate a synthetic type name and analyze it
5224        let synthetic_name = if suffix == "Request" {
5225            self.generate_inline_request_type_name(operation_id)
5226        } else {
5227            self.generate_inline_response_type_name(operation_id, "")
5228        };
5229        let mut deps = HashSet::new();
5230        self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
5231        Ok(synthetic_name)
5232    }
5233
5234    /// Resolve a parameter reference ($ref) to the actual parameter definition.
5235    /// Returns the resolved parameter, or the original if it's not a reference.
5236    fn resolve_parameter<'a>(
5237        &'a self,
5238        param: &'a crate::openapi::Parameter,
5239    ) -> std::borrow::Cow<'a, crate::openapi::Parameter> {
5240        if let Some(ref_str) = param.reference.as_deref() {
5241            if let Some(param_name) = ref_str.strip_prefix("#/components/parameters/") {
5242                if let Some(resolved) = self.component_parameters.get(param_name) {
5243                    return std::borrow::Cow::Borrowed(resolved);
5244                }
5245            }
5246        }
5247        std::borrow::Cow::Borrowed(param)
5248    }
5249
5250    /// Analyze a parameter.
5251    ///
5252    /// `operation_id` is used to generate a unique synthetic enum type name
5253    /// when the parameter's inline schema is a string with `enum` or `const`
5254    /// (e.g. `GetItemTheConstant`). The client generator emits the enum
5255    /// alongside the operation methods. See issue #10 follow-up.
5256    /// Look up `#/components/schemas/{name}` in the raw OpenAPI document and
5257    /// decide whether it's a string with enum values. Used by analyze_parameter
5258    /// (T10). String-enum refs flow through to the codegen-typed parameter
5259    /// path; object refs are typed only when form-exploded (issue #27), and
5260    /// other struct refs stay `String` until deepObject / explode=false
5261    /// serialization is generated (T14).
5262    fn referenced_schema_is_string_enum(&self, name: &str) -> bool {
5263        if self.resolve_cached_schema(name).is_some_and(|schema| {
5264            matches!(
5265                schema.schema_type,
5266                SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. }
5267            )
5268        }) {
5269            return true;
5270        }
5271        let Some(schema_value) = self
5272            .openapi_spec
5273            .get("components")
5274            .and_then(|c| c.get("schemas"))
5275            .and_then(|s| s.get(name))
5276        else {
5277            return false;
5278        };
5279        let is_string_type = schema_value
5280            .get("type")
5281            .and_then(|v| v.as_str())
5282            .map(|s| s == "string")
5283            .unwrap_or(false);
5284        let has_enum_or_const =
5285            schema_value.get("enum").is_some() || schema_value.get("const").is_some();
5286        is_string_type && has_enum_or_const
5287    }
5288
5289    fn resolve_raw_local_reference(&self, value: &Value) -> Option<Value> {
5290        let Some(reference) = value.get("$ref").and_then(Value::as_str) else {
5291            return Some(value.clone());
5292        };
5293        let pointer = reference.strip_prefix('#')?;
5294        self.openapi_spec.pointer(pointer).cloned()
5295    }
5296
5297    fn raw_request_body_schema(
5298        &self,
5299        operation: Option<&Value>,
5300        content_type: &str,
5301    ) -> Option<Value> {
5302        let request_body = operation?.get("requestBody")?;
5303        self.resolve_raw_local_reference(request_body)?
5304            .get("content")?
5305            .get(content_type)?
5306            .get("schema")
5307            .cloned()
5308    }
5309
5310    fn raw_parameter_schema(&self, parameter: &Value) -> Option<Value> {
5311        self.resolve_raw_local_reference(parameter)?
5312            .get("schema")
5313            .cloned()
5314    }
5315
5316    fn analyze_parameter(
5317        &mut self,
5318        param: &crate::openapi::Parameter,
5319        operation_id: &str,
5320        raw_validation_schema: Option<Value>,
5321    ) -> Result<Option<ParameterInfo>> {
5322        use heck::ToPascalCase;
5323
5324        let name = param.name.as_deref().unwrap_or("");
5325        let location = param.location.as_deref().unwrap_or("");
5326        let required = param.required.unwrap_or(false);
5327        let validation_schema = match raw_validation_schema {
5328            Some(schema) => Some(schema),
5329            None => param
5330                .schema
5331                .as_ref()
5332                .map(serde_json::to_value)
5333                .transpose()
5334                .map_err(GeneratorError::ParseError)?,
5335        };
5336
5337        let mut rust_type = "String".to_string();
5338        let mut schema_ref = None;
5339        let mut enum_values: Option<Vec<String>> = None;
5340        let mut enum_varnames: Option<Vec<String>> = None;
5341        let mut query_serialization: Option<QuerySerialization> = None;
5342
5343        // OAS 3.x style/explode resolution for `in: query`. Defaults are
5344        // style=form and — for form only — explode=true, so an object/array
5345        // query parameter with nothing specified is already form-exploded
5346        // per spec (issue #27). deepObject is only defined with explode=true;
5347        // an explicit explode=false there is undefined and keeps the fallback.
5348        let is_query = location == "query";
5349        let is_simple_header = location == "header"
5350            && matches!(param.style.as_deref(), None | Some("simple"))
5351            && param.explode != Some(true);
5352        let form_style = matches!(param.style.as_deref(), None | Some("form"));
5353        let form_exploded = form_style && param.explode.unwrap_or(true);
5354        let deep_object =
5355            param.style.as_deref() == Some("deepObject") && param.explode != Some(false);
5356
5357        let object_serialization = if !is_query {
5358            None
5359        } else if deep_object {
5360            Some(QuerySerialization::DeepObject)
5361        } else if form_exploded {
5362            Some(QuerySerialization::FormExplodedObject)
5363        } else if form_style {
5364            Some(QuerySerialization::FormObject)
5365        } else {
5366            None
5367        };
5368
5369        if let Some(schema) = &param.schema {
5370            if let Some(ref_str) = schema.reference() {
5371                // T10: keep the resolved type when the target is a string-enum
5372                // (then `Display`/`as_str` are emitted, see generate_string_enum).
5373                // Object refs on query params with a generated wire style keep
5374                // the resolved struct type too (T14/issue #27); anything else
5375                // stays on the opaque `String` fallback.
5376                if let Some(name) = self.extract_schema_name(ref_str) {
5377                    if self.referenced_schema_is_string_enum(name) {
5378                        schema_ref = Some(name.to_string());
5379                    } else if object_serialization.is_some()
5380                        && self.referenced_schema_is_object(name)
5381                    {
5382                        schema_ref = Some(name.to_string());
5383                        query_serialization = if form_exploded && self.uses_aws_query_conventions()
5384                        {
5385                            match self.referenced_array_struct_item_type(name, 1) {
5386                                Some(ArrayItemType::NestedStructRef { properties, .. }) => {
5387                                    Some(QuerySerialization::FormExplodedNestedObject {
5388                                        properties,
5389                                    })
5390                                }
5391                                _ => object_serialization.clone(),
5392                            }
5393                        } else {
5394                            object_serialization.clone()
5395                        };
5396                    } else if (is_query && form_style || is_simple_header)
5397                        && let Some(item_type) = self.referenced_array_param_item_type(name)
5398                    {
5399                        // A parameter may reference a reusable array schema
5400                        // rather than declaring `type: array` inline. Preserve
5401                        // that component as a pruning root while projecting the
5402                        // public parameter type to the same Vec<T> used by
5403                        // inline arrays.
5404                        schema_ref = Some(name.to_string());
5405                        query_serialization = Some(if is_simple_header {
5406                            QuerySerialization::SimpleHeaderArray { item_type }
5407                        } else if form_exploded {
5408                            QuerySerialization::FormExplodedArray { item_type }
5409                        } else {
5410                            QuerySerialization::FormArray { item_type }
5411                        });
5412                    }
5413                }
5414            } else if object_serialization.is_some() && Self::schema_is_inline_object(schema) {
5415                // Inline object schema on a query parameter with a generated
5416                // wire style: synthesize a struct (e.g. `FindWidgetsFilter`)
5417                // so the caller passes typed fields instead of a pre-encoded
5418                // string.
5419                let op_pascal = operation_id.replace('.', "_").to_pascal_case();
5420                let param_pascal = name.to_pascal_case();
5421                let synthetic_name = format!("{op_pascal}{param_pascal}");
5422                let mut deps = HashSet::new();
5423                self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
5424                schema_ref = Some(synthetic_name.clone());
5425                query_serialization = if form_exploded && self.uses_aws_query_conventions() {
5426                    match self.referenced_array_struct_item_type(&synthetic_name, 1) {
5427                        Some(ArrayItemType::NestedStructRef { properties, .. }) => {
5428                            Some(QuerySerialization::FormExplodedNestedObject { properties })
5429                        }
5430                        _ => object_serialization.clone(),
5431                    }
5432                } else {
5433                    object_serialization.clone()
5434                };
5435            } else if (is_query && form_style || is_simple_header)
5436                && matches!(
5437                    schema.schema_type(),
5438                    Some(crate::openapi::SchemaType::Array)
5439                )
5440                && let Some(item_type) = self.array_param_item_type(schema)
5441            {
5442                // Typed form-style array (openapi-generator-anu): the client
5443                // takes `Vec<item_type>` and emits repeated (explode=true) or
5444                // comma-joined (explode=false) pairs. `rust_type` deliberately
5445                // stays "String" because the shared query-serialization plan
5446                // is the authoritative Vec<T> projection. Arrays whose items
5447                // don't type (objects, nested arrays) fall through to the
5448                // explicit unsupported shape below.
5449                query_serialization = Some(if is_simple_header {
5450                    QuerySerialization::SimpleHeaderArray { item_type }
5451                } else if form_exploded {
5452                    QuerySerialization::FormExplodedArray { item_type }
5453                } else {
5454                    QuerySerialization::FormArray { item_type }
5455                });
5456            } else if let Some(schema_type) = schema.schema_type() {
5457                // Route integer/number through the same TypeMapper the schema
5458                // property path uses (see analyze_property), so `format: int32`
5459                // yields `i32` and `[type_mappings]`/strategy config applies to
5460                // parameters too. Hardcoding `i64`/`f64` here previously made
5461                // `format` and config impossible to honour for query/path params.
5462                let format = schema.details().format.clone();
5463                rust_type = match schema_type {
5464                    crate::openapi::SchemaType::Boolean => "bool".to_string(),
5465                    crate::openapi::SchemaType::Integer => {
5466                        self.type_mapper.integer_format(format.as_deref()).rust_type
5467                    }
5468                    crate::openapi::SchemaType::Number => {
5469                        self.type_mapper.number_format(format.as_deref()).rust_type
5470                    }
5471                    crate::openapi::SchemaType::String => "String".to_string(),
5472                    _ => "String".to_string(),
5473                };
5474
5475                if matches!(schema_type, crate::openapi::SchemaType::String) {
5476                    let details = schema.details();
5477                    if details.is_string_enum() {
5478                        if let Some(values) = details.string_enum_values() {
5479                            if !values.is_empty() {
5480                                let op_pascal = operation_id.replace('.', "_").to_pascal_case();
5481                                let param_pascal = name.to_pascal_case();
5482                                rust_type = format!("{op_pascal}{param_pascal}");
5483                                // Honor `x-enum-varnames` here the same way
5484                                // schema-level enums do. A mismatched length is
5485                                // ambiguous about which value each name refers
5486                                // to, so drop it rather than guess.
5487                                enum_varnames = details
5488                                    .extra
5489                                    .get("x-enum-varnames")
5490                                    .and_then(Value::as_array)
5491                                    .map(|raw| {
5492                                        raw.iter()
5493                                            .filter_map(Value::as_str)
5494                                            .map(str::to_owned)
5495                                            .collect::<Vec<_>>()
5496                                    })
5497                                    .filter(|names| names.len() == values.len());
5498                                enum_values = Some(values);
5499                            }
5500                        }
5501                    }
5502                }
5503            }
5504
5505            if is_query && query_serialization.is_none() {
5506                let referenced_name = schema
5507                    .reference()
5508                    .and_then(|reference| self.extract_schema_name(reference));
5509                let is_object = referenced_name
5510                    .is_some_and(|name| self.referenced_schema_is_object(name))
5511                    || Self::schema_is_inline_object(schema);
5512                let is_array = referenced_name
5513                    .is_some_and(|name| self.referenced_schema_is_array(name))
5514                    || matches!(
5515                        schema.schema_type(),
5516                        Some(crate::openapi::SchemaType::Array)
5517                    );
5518                let is_composed = referenced_name
5519                    .is_some_and(|name| self.referenced_schema_is_composed_query_shape(name));
5520                let reason = if param.style.as_deref() == Some("deepObject")
5521                    && param.explode == Some(false)
5522                {
5523                    Some("style=deepObject with explode=false is undefined by OpenAPI".to_string())
5524                } else if param.style.as_deref() == Some("deepObject") && !is_object {
5525                    Some("style=deepObject is defined only for object query parameters".to_string())
5526                } else if is_object {
5527                    Some(format!(
5528                        "object query parameters do not support style={}",
5529                        param.style.as_deref().unwrap_or("form")
5530                    ))
5531                } else if is_array && form_style {
5532                    Some(
5533                        "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"
5534                            .to_string(),
5535                    )
5536                } else if is_array {
5537                    Some(format!(
5538                        "array query parameters do not yet support style={}",
5539                        param.style.as_deref().unwrap_or("form")
5540                    ))
5541                } else if is_composed {
5542                    Some(
5543                        "composed or union query schemas cannot be projected to an unambiguous flat wire shape"
5544                            .to_string(),
5545                    )
5546                } else {
5547                    None
5548                };
5549                if let Some(reason) = reason {
5550                    query_serialization = Some(QuerySerialization::Unsupported { reason });
5551                }
5552            }
5553        }
5554
5555        Ok(Some(ParameterInfo {
5556            name: name.to_string(),
5557            location: location.to_string(),
5558            required,
5559            schema_ref,
5560            rust_type,
5561            description: param.description.clone(),
5562            enum_values,
5563            enum_varnames,
5564            rust_ident: None,
5565            query_serialization,
5566            validation_schema,
5567        }))
5568    }
5569
5570    /// Rust item type for a typed array query parameter
5571    /// (openapi-generator-anu). Scalar items map through the TypeMapper;
5572    /// $ref items resolve when the target is a scalar alias or generated
5573    /// string enum (both support the client/server string wire projection).
5574    /// Anything else — objects, nested arrays — returns None and the
5575    /// parameter keeps the opaque-string fallback. Inline-enum'd string
5576    /// items stay plain `String`: the op-scoped enum synthesis (issue #10)
5577    /// is wired for scalar params only.
5578    fn array_param_item_type(&self, schema: &crate::openapi::Schema) -> Option<ArrayItemType> {
5579        let items = schema.details().items.as_deref()?;
5580        // AWS query-protocol specs wrap item refs in an annotation-only allOf
5581        // (`items: {allOf: [$ref, {xml: ...}]}`). See through the wrapper when
5582        // every sibling is annotation-only, mirroring the type-alias rule.
5583        let unwrapped = unwrap_annotation_allof(items);
5584        if let Some(ref_str) = unwrapped.reference() {
5585            let name = self.extract_schema_name(ref_str)?;
5586            return self
5587                .referenced_array_scalar_item_type(name)
5588                .or_else(|| self.referenced_array_struct_item_type(name, 1));
5589        }
5590        let format = unwrapped.details().format.clone();
5591        let scalar = match unwrapped.schema_type()? {
5592            crate::openapi::SchemaType::String => "String".to_string(),
5593            crate::openapi::SchemaType::Integer => {
5594                self.type_mapper.integer_format(format.as_deref()).rust_type
5595            }
5596            crate::openapi::SchemaType::Number => {
5597                self.type_mapper.number_format(format.as_deref()).rust_type
5598            }
5599            crate::openapi::SchemaType::Boolean => "bool".to_string(),
5600            _ => return None,
5601        };
5602        Some(ArrayItemType::Scalar(scalar))
5603    }
5604
5605    /// Resolve a reusable component array (including `$ref` aliases) and
5606    /// apply the same item projection as an inline array parameter.
5607    fn referenced_array_param_item_type(&self, name: &str) -> Option<ArrayItemType> {
5608        let schema = self.resolve_cached_schema(name)?;
5609        let SchemaType::Array { item_type } = &schema.schema_type else {
5610            return None;
5611        };
5612        self.analyzed_array_item_type(item_type)
5613    }
5614
5615    fn analyzed_array_item_type(&self, item_type: &SchemaType) -> Option<ArrayItemType> {
5616        self.analyzed_array_item_type_at_depth(item_type, 1)
5617    }
5618
5619    /// Accept a referenced structure as a form-style array item when every
5620    /// property is scalar (AWS query-protocol flat structures such as
5621    /// `Tag { Key, Value }`). Nested objects, arrays, and maps are rejected
5622    /// because the wire shape below one level is service-specific.
5623    fn referenced_array_struct_item_type(
5624        &self,
5625        name: &str,
5626        nested_array_depth: usize,
5627    ) -> Option<ArrayItemType> {
5628        let resolved = self.resolve_cached_schema(name)?;
5629        let SchemaType::Object {
5630            properties,
5631            required,
5632            additional_properties,
5633        } = &resolved.schema_type
5634        else {
5635            return None;
5636        };
5637        if properties.is_empty()
5638            || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
5639        {
5640            return None;
5641        }
5642        let mut projected = Vec::with_capacity(properties.len());
5643        let mut has_array = false;
5644        for (wire_name, property) in properties {
5645            let value_type = if let Some(scalar) = self.query_scalar_type(&property.schema_type) {
5646                QueryStructPropertyType::Scalar(scalar)
5647            } else {
5648                if nested_array_depth == 0 {
5649                    return None;
5650                }
5651                if let Some(array) = self.resolve_query_array_type(&property.schema_type) {
5652                    let item_type =
5653                        self.analyzed_array_item_type_at_depth(array, nested_array_depth - 1)?;
5654                    if matches!(item_type, ArrayItemType::NestedStructRef { .. }) {
5655                        return None;
5656                    }
5657                    has_array = true;
5658                    QueryStructPropertyType::Array { item_type }
5659                } else {
5660                    has_array = true;
5661                    QueryStructPropertyType::Object {
5662                        properties: self.query_flat_object_properties(&property.schema_type)?,
5663                    }
5664                }
5665            };
5666            projected.push(QueryStructProperty {
5667                wire_name: wire_name.clone(),
5668                required: required.contains(wire_name),
5669                value_type,
5670            });
5671        }
5672        if has_array {
5673            Some(ArrayItemType::NestedStructRef {
5674                schema_name: name.to_string(),
5675                properties: projected,
5676            })
5677        } else {
5678            Some(ArrayItemType::FlatStructRef {
5679                schema_name: name.to_string(),
5680                properties: projected,
5681            })
5682        }
5683    }
5684
5685    fn analyzed_array_item_type_at_depth(
5686        &self,
5687        item_type: &SchemaType,
5688        nested_array_depth: usize,
5689    ) -> Option<ArrayItemType> {
5690        match item_type {
5691            SchemaType::Primitive { rust_type, .. } => {
5692                Some(ArrayItemType::Scalar(rust_type.clone()))
5693            }
5694            SchemaType::Reference { target } => self
5695                .referenced_array_scalar_item_type(target)
5696                .or_else(|| self.referenced_array_struct_item_type(target, nested_array_depth)),
5697            _ => None,
5698        }
5699    }
5700
5701    fn resolve_query_array_type<'a>(
5702        &'a self,
5703        schema_type: &'a SchemaType,
5704    ) -> Option<&'a SchemaType> {
5705        match schema_type {
5706            SchemaType::Array { item_type } => Some(item_type),
5707            SchemaType::Reference { target } => {
5708                let resolved = self.resolve_cached_schema(target)?;
5709                let SchemaType::Array { item_type } = &resolved.schema_type else {
5710                    return None;
5711                };
5712                Some(item_type)
5713            }
5714            _ => None,
5715        }
5716    }
5717
5718    fn query_flat_object_properties(
5719        &self,
5720        schema_type: &SchemaType,
5721    ) -> Option<Vec<QueryStructProperty>> {
5722        let schema_type = match schema_type {
5723            SchemaType::Reference { target } => &self.resolve_cached_schema(target)?.schema_type,
5724            other => other,
5725        };
5726        let SchemaType::Object {
5727            properties,
5728            required,
5729            additional_properties,
5730        } = schema_type
5731        else {
5732            return None;
5733        };
5734        if properties.is_empty()
5735            || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
5736        {
5737            return None;
5738        }
5739        properties
5740            .iter()
5741            .map(|(wire_name, property)| {
5742                Some(QueryStructProperty {
5743                    wire_name: wire_name.clone(),
5744                    required: required.contains(wire_name),
5745                    value_type: QueryStructPropertyType::Scalar(
5746                        self.query_scalar_type(&property.schema_type)?,
5747                    ),
5748                })
5749            })
5750            .collect()
5751    }
5752
5753    fn query_scalar_type(&self, schema_type: &SchemaType) -> Option<QueryScalarType> {
5754        match schema_type {
5755            SchemaType::Primitive { rust_type, .. } => match rust_type.as_str() {
5756                "String" => Some(QueryScalarType::String),
5757                "bool" => Some(QueryScalarType::Boolean),
5758                value if value.starts_with('i') || value.starts_with('u') => {
5759                    Some(QueryScalarType::Integer)
5760                }
5761                value if value.starts_with('f') => Some(QueryScalarType::Number),
5762                "serde_json::Value" => None,
5763                _ => Some(QueryScalarType::String),
5764            },
5765            SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => {
5766                Some(QueryScalarType::String)
5767            }
5768            SchemaType::Reference { target } => {
5769                let resolved = self.resolve_cached_schema(target)?;
5770                self.query_scalar_type(&resolved.schema_type)
5771            }
5772            _ => None,
5773        }
5774    }
5775
5776    /// Resolve a referenced array item through any alias chain while
5777    /// preserving the outer schema name used by the public `Vec<T>` type.
5778    ///
5779    /// `SchemaType::Primitive` also represents dynamic JSON/object fallbacks,
5780    /// so require an actual OpenAPI scalar `type` before accepting it as a
5781    /// form-style query item. Unresolved and cyclic chains are rejected by
5782    /// `resolve_cached_schema`.
5783    fn referenced_array_scalar_item_type(&self, name: &str) -> Option<ArrayItemType> {
5784        let resolved = self.resolve_cached_schema(name)?;
5785        let supported = match &resolved.schema_type {
5786            SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => true,
5787            SchemaType::Primitive { .. } => resolved
5788                .original
5789                .get("type")
5790                .is_some_and(Self::query_scalar_type_value),
5791            _ => false,
5792        };
5793        supported.then(|| ArrayItemType::SchemaRef(name.to_string()))
5794    }
5795
5796    fn query_scalar_type_value(value: &Value) -> bool {
5797        const SCALARS: [&str; 4] = ["string", "integer", "number", "boolean"];
5798        if let Some(value) = value.as_str() {
5799            return SCALARS.contains(&value);
5800        }
5801        let Some(values) = value.as_array() else {
5802            return false;
5803        };
5804        if !values.iter().all(Value::is_string) {
5805            return false;
5806        }
5807        let mut non_null = values
5808            .iter()
5809            .filter_map(Value::as_str)
5810            .filter(|value| *value != "null");
5811        let Some(scalar) = non_null.next() else {
5812            return false;
5813        };
5814        non_null.next().is_none() && SCALARS.contains(&scalar)
5815    }
5816
5817    /// True when a component (following `$ref` aliases) analyzes to an object.
5818    /// Used to decide whether a referenced query parameter can use a typed
5819    /// object serialization plan (issue #27).
5820    fn referenced_schema_is_object(&self, name: &str) -> bool {
5821        self.resolve_cached_schema(name)
5822            .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Object { .. }))
5823    }
5824
5825    fn referenced_schema_is_array(&self, name: &str) -> bool {
5826        self.resolve_cached_schema(name)
5827            .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Array { .. }))
5828    }
5829
5830    fn referenced_schema_is_composed_query_shape(&self, name: &str) -> bool {
5831        self.resolve_cached_schema(name).is_some_and(|schema| {
5832            matches!(
5833                schema.schema_type,
5834                SchemaType::Composition { .. }
5835                    | SchemaType::Union { .. }
5836                    | SchemaType::DiscriminatedUnion { .. }
5837            )
5838        })
5839    }
5840
5841    fn resolve_cached_schema(&self, name: &str) -> Option<&AnalyzedSchema> {
5842        let mut current = name;
5843        let mut visited = HashSet::new();
5844        loop {
5845            if !visited.insert(current) {
5846                return None;
5847            }
5848            let schema = self.resolved_cache.get(current)?;
5849            if let SchemaType::Reference { target } = &schema.schema_type {
5850                current = target;
5851            } else {
5852                return Some(schema);
5853            }
5854        }
5855    }
5856
5857    /// Inline-schema counterpart of [`Self::referenced_schema_is_object`].
5858    fn schema_is_inline_object(schema: &crate::openapi::Schema) -> bool {
5859        match schema.schema_type() {
5860            Some(crate::openapi::SchemaType::Object) => true,
5861            None => schema.details().properties.is_some(),
5862            _ => false,
5863        }
5864    }
5865}
5866
5867fn disambiguate_component_schema_names(openapi_spec: &mut Value) {
5868    let Some(schemas) = openapi_spec
5869        .pointer_mut("/components/schemas")
5870        .and_then(Value::as_object_mut)
5871    else {
5872        return;
5873    };
5874
5875    let mut names_by_rust_name = BTreeMap::<String, Vec<String>>::new();
5876    for name in schemas.keys() {
5877        names_by_rust_name
5878            .entry(crate::generator::rust_type_name(name))
5879            .or_default()
5880            .push(name.clone());
5881    }
5882
5883    // Reserve every identifier already represented by the document so a
5884    // suffix never steals another component's canonical Rust name.
5885    let mut claimed_rust_names = names_by_rust_name.keys().cloned().collect::<HashSet<_>>();
5886    let mut aliases = BTreeMap::<String, String>::new();
5887
5888    for (rust_name, mut names) in names_by_rust_name {
5889        if names.len() < 2 {
5890            continue;
5891        }
5892
5893        // Prefer an already-canonical component key (for example `Alert`
5894        // over `alert`), then use lexical order for deterministic results.
5895        names.sort_by_key(|name| (name != &rust_name, name.clone()));
5896        for source_name in names.into_iter().skip(1) {
5897            let mut suffix = 2;
5898            let replacement = loop {
5899                let candidate = format!("{rust_name}{suffix}");
5900                if claimed_rust_names.insert(candidate.clone()) {
5901                    break candidate;
5902                }
5903                suffix += 1;
5904            };
5905
5906            eprintln!(
5907                "⚠️  schema `{source_name}` maps to the existing Rust type `{rust_name}` — disambiguated to `{replacement}`"
5908            );
5909            aliases.insert(source_name, replacement);
5910        }
5911    }
5912
5913    if aliases.is_empty() {
5914        return;
5915    }
5916
5917    let original_schemas = std::mem::take(schemas);
5918    for (name, schema) in original_schemas {
5919        schemas.insert(aliases.get(&name).cloned().unwrap_or(name), schema);
5920    }
5921
5922    rewrite_component_schema_references(openapi_spec, &aliases);
5923}
5924
5925fn disambiguate_analyzed_schema_names(
5926    analysis: &mut SchemaAnalysis,
5927    component_schemas: &BTreeMap<String, Schema>,
5928) {
5929    let mut names_by_rust_name = BTreeMap::<String, Vec<String>>::new();
5930    for name in analysis.schemas.keys() {
5931        names_by_rust_name
5932            .entry(crate::generator::rust_type_name(name))
5933            .or_default()
5934            .push(name.clone());
5935    }
5936
5937    let mut claimed_rust_names = names_by_rust_name.keys().cloned().collect::<HashSet<_>>();
5938    let mut aliases = BTreeMap::<String, String>::new();
5939
5940    for (rust_name, mut names) in names_by_rust_name {
5941        if names.len() < 2 {
5942            continue;
5943        }
5944        names.sort_by_key(|name| {
5945            (
5946                !component_schemas.contains_key(name),
5947                name != &rust_name,
5948                name.clone(),
5949            )
5950        });
5951
5952        for source_name in names.into_iter().skip(1) {
5953            let mut suffix = 2;
5954            let replacement = loop {
5955                let candidate = format!("{rust_name}{suffix}");
5956                if claimed_rust_names.insert(candidate.clone()) {
5957                    break candidate;
5958                }
5959                suffix += 1;
5960            };
5961            eprintln!(
5962                "⚠️  generated schema `{source_name}` maps to the existing Rust type `{rust_name}` — disambiguated to `{replacement}`"
5963            );
5964            aliases.insert(source_name, replacement);
5965        }
5966    }
5967
5968    if aliases.is_empty() {
5969        return;
5970    }
5971
5972    let original_schemas = std::mem::take(&mut analysis.schemas);
5973    for (name, mut schema) in original_schemas {
5974        schema.name = renamed_schema_name(&schema.name, &aliases);
5975        schema.dependencies = schema
5976            .dependencies
5977            .into_iter()
5978            .map(|name| renamed_schema_name(&name, &aliases))
5979            .collect();
5980        rewrite_schema_type_names(&mut schema.schema_type, &aliases);
5981        analysis
5982            .schemas
5983            .insert(renamed_schema_name(&name, &aliases), schema);
5984    }
5985
5986    let original_edges = std::mem::take(&mut analysis.dependencies.edges);
5987    for (name, dependencies) in original_edges {
5988        analysis.dependencies.edges.insert(
5989            renamed_schema_name(&name, &aliases),
5990            dependencies
5991                .into_iter()
5992                .map(|name| renamed_schema_name(&name, &aliases))
5993                .collect(),
5994        );
5995    }
5996    analysis.dependencies.recursive_schemas = analysis
5997        .dependencies
5998        .recursive_schemas
5999        .iter()
6000        .map(|name| renamed_schema_name(name, &aliases))
6001        .collect();
6002
6003    analysis.patterns.tagged_enum_schemas = analysis
6004        .patterns
6005        .tagged_enum_schemas
6006        .iter()
6007        .map(|name| renamed_schema_name(name, &aliases))
6008        .collect();
6009    analysis.patterns.untagged_enum_schemas = analysis
6010        .patterns
6011        .untagged_enum_schemas
6012        .iter()
6013        .map(|name| renamed_schema_name(name, &aliases))
6014        .collect();
6015    analysis.patterns.type_mappings = std::mem::take(&mut analysis.patterns.type_mappings)
6016        .into_iter()
6017        .map(|(name, mappings)| {
6018            (
6019                renamed_schema_name(&name, &aliases),
6020                mappings
6021                    .into_iter()
6022                    .map(|(value, schema_name)| {
6023                        (value, renamed_schema_name(&schema_name, &aliases))
6024                    })
6025                    .collect(),
6026            )
6027        })
6028        .collect();
6029
6030    for operation in analysis.operations.values_mut() {
6031        if let Some(request_body) = &mut operation.request_body {
6032            rewrite_request_body_schema_name(request_body, &aliases);
6033        }
6034        for schema_name in operation.response_schemas.values_mut() {
6035            *schema_name = renamed_schema_name(schema_name, &aliases);
6036        }
6037        for parameter in &mut operation.parameters {
6038            if let Some(schema_name) = &mut parameter.schema_ref {
6039                *schema_name = renamed_schema_name(schema_name, &aliases);
6040            }
6041            if let Some(serialization) = &mut parameter.query_serialization {
6042                rewrite_query_serialization_schema_names(serialization, &aliases);
6043            }
6044        }
6045    }
6046
6047    for responses in analysis.operation_responses.values_mut() {
6048        for response in responses.values_mut() {
6049            if let Some(schema_name) = &mut response.schema_name {
6050                *schema_name = renamed_schema_name(schema_name, &aliases);
6051            }
6052            if let Some(OperationResponseBody::Json { schema_name, .. }) = &mut response.body {
6053                *schema_name = renamed_schema_name(schema_name, &aliases);
6054            }
6055        }
6056    }
6057}
6058
6059fn renamed_schema_name(name: &str, aliases: &BTreeMap<String, String>) -> String {
6060    aliases
6061        .get(name)
6062        .cloned()
6063        .unwrap_or_else(|| name.to_string())
6064}
6065
6066fn rewrite_schema_type_names(schema_type: &mut SchemaType, aliases: &BTreeMap<String, String>) {
6067    match schema_type {
6068        SchemaType::Object {
6069            properties,
6070            additional_properties,
6071            ..
6072        } => {
6073            for property in properties.values_mut() {
6074                rewrite_schema_type_names(&mut property.schema_type, aliases);
6075            }
6076            if let ObjectAdditionalProperties::Typed { value_type } = additional_properties {
6077                rewrite_schema_type_names(value_type, aliases);
6078            }
6079        }
6080        SchemaType::DiscriminatedUnion { variants, .. } => {
6081            for variant in variants {
6082                variant.type_name = renamed_schema_name(&variant.type_name, aliases);
6083                variant.schema_ref = renamed_schema_name(&variant.schema_ref, aliases);
6084            }
6085        }
6086        SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => {
6087            for variant in variants {
6088                variant.target = renamed_schema_name(&variant.target, aliases);
6089            }
6090        }
6091        SchemaType::Array { item_type } => rewrite_schema_type_names(item_type, aliases),
6092        SchemaType::Reference { target } => {
6093            *target = renamed_schema_name(target, aliases);
6094        }
6095        SchemaType::Primitive { .. }
6096        | SchemaType::StringEnum { .. }
6097        | SchemaType::ExtensibleEnum { .. } => {}
6098    }
6099}
6100
6101fn rewrite_request_body_schema_name(
6102    request_body: &mut RequestBodyContent,
6103    aliases: &BTreeMap<String, String>,
6104) {
6105    match request_body {
6106        RequestBodyContent::Json { schema_name, .. }
6107        | RequestBodyContent::FormUrlEncoded { schema_name, .. }
6108        | RequestBodyContent::Multipart { schema_name, .. } => {
6109            *schema_name = renamed_schema_name(schema_name, aliases);
6110        }
6111        _ => {}
6112    }
6113}
6114
6115fn rewrite_query_serialization_schema_names(
6116    serialization: &mut QuerySerialization,
6117    aliases: &BTreeMap<String, String>,
6118) {
6119    match serialization {
6120        QuerySerialization::FormExplodedArray { item_type }
6121        | QuerySerialization::FormArray { item_type }
6122        | QuerySerialization::SimpleHeaderArray { item_type } => {
6123            rewrite_array_item_type_schema_names(item_type, aliases);
6124        }
6125        QuerySerialization::FormExplodedNestedObject { properties } => {
6126            for property in properties {
6127                rewrite_query_property_type_schema_names(&mut property.value_type, aliases);
6128            }
6129        }
6130        _ => {}
6131    }
6132}
6133
6134fn rewrite_array_item_type_schema_names(
6135    item_type: &mut ArrayItemType,
6136    aliases: &BTreeMap<String, String>,
6137) {
6138    match item_type {
6139        ArrayItemType::SchemaRef(name) => *name = renamed_schema_name(name, aliases),
6140        ArrayItemType::FlatStructRef {
6141            schema_name,
6142            properties,
6143        }
6144        | ArrayItemType::NestedStructRef {
6145            schema_name,
6146            properties,
6147        } => {
6148            *schema_name = renamed_schema_name(schema_name, aliases);
6149            for property in properties {
6150                rewrite_query_property_type_schema_names(&mut property.value_type, aliases);
6151            }
6152        }
6153        ArrayItemType::Scalar(_) => {}
6154    }
6155}
6156
6157fn rewrite_query_property_type_schema_names(
6158    property_type: &mut QueryStructPropertyType,
6159    aliases: &BTreeMap<String, String>,
6160) {
6161    match property_type {
6162        QueryStructPropertyType::Array { item_type } => {
6163            rewrite_array_item_type_schema_names(item_type, aliases)
6164        }
6165        QueryStructPropertyType::Object { properties } => {
6166            for property in properties {
6167                rewrite_query_property_type_schema_names(&mut property.value_type, aliases);
6168            }
6169        }
6170        QueryStructPropertyType::Scalar(_) => {}
6171    }
6172}
6173
6174fn rewrite_component_schema_references(value: &mut Value, aliases: &BTreeMap<String, String>) {
6175    match value {
6176        Value::Array(values) => {
6177            for value in values {
6178                rewrite_component_schema_references(value, aliases);
6179            }
6180        }
6181        Value::Object(object) => {
6182            if let Some(Value::String(reference)) = object.get_mut("$ref") {
6183                rewrite_component_schema_reference(reference, aliases);
6184            }
6185
6186            if let Some(Value::Object(mapping)) = object.get_mut("mapping") {
6187                for target_value in mapping.values_mut() {
6188                    let Some(target) = target_value.as_str() else {
6189                        continue;
6190                    };
6191                    let replacement = aliases.get(target).cloned().or_else(|| {
6192                        let mut target = target.to_string();
6193                        rewrite_component_schema_reference(&mut target, aliases).then_some(target)
6194                    });
6195                    if let Some(replacement) = replacement {
6196                        *target_value = Value::String(replacement);
6197                    }
6198                }
6199            }
6200
6201            for value in object.values_mut() {
6202                rewrite_component_schema_references(value, aliases);
6203            }
6204        }
6205        _ => {}
6206    }
6207}
6208
6209fn rewrite_component_schema_reference(
6210    reference: &mut String,
6211    aliases: &BTreeMap<String, String>,
6212) -> bool {
6213    const PREFIX: &str = "#/components/schemas/";
6214    let Some(encoded_name) = reference.strip_prefix(PREFIX) else {
6215        return false;
6216    };
6217    let encoded_name = encoded_name.split('/').next().unwrap_or(encoded_name);
6218
6219    for (source, replacement) in aliases {
6220        let encoded_source = source.replace('~', "~0").replace('/', "~1");
6221        if encoded_name == encoded_source {
6222            reference.replace_range(
6223                PREFIX.len()..PREFIX.len() + encoded_source.len(),
6224                replacement,
6225            );
6226            return true;
6227        }
6228    }
6229
6230    false
6231}