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