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::Deserialize;
5use serde_json::Value;
6use std::collections::{BTreeMap, HashSet};
7use std::path::Path;
8
9/// Q2.6 — pull `x-enum-varnames` / `x-enum-descriptions` arrays off
10/// the schema's original JSON. Both extensions must be string arrays
11/// matching the enum-value count; mismatched extensions are dropped
12/// with a stderr warning so they can't subtly break codegen.
13///
14/// Returns `None` when neither extension is present.
15fn extract_enum_extensions(
16    original: &Value,
17    enum_value_count: usize,
18    schema_name: &str,
19) -> Option<EnumExtensions> {
20    let obj = original.as_object()?;
21
22    let read_string_array = |key: &str| -> Option<Vec<String>> {
23        let arr = obj.get(key)?.as_array()?;
24        let mut out = Vec::with_capacity(arr.len());
25        for v in arr {
26            out.push(v.as_str()?.to_string());
27        }
28        Some(out)
29    };
30
31    let varnames_raw = read_string_array("x-enum-varnames");
32    let descriptions_raw = read_string_array("x-enum-descriptions");
33
34    if varnames_raw.is_none() && descriptions_raw.is_none() {
35        return None;
36    }
37
38    let validate = |label: &str, vals: Option<Vec<String>>| -> Vec<String> {
39        let Some(vals) = vals else {
40            return Vec::new();
41        };
42        if vals.len() == enum_value_count {
43            vals
44        } else {
45            eprintln!(
46                "⚠️  {schema_name}: dropping {label} (expected {enum_value_count} entries, got {})",
47                vals.len()
48            );
49            Vec::new()
50        }
51    };
52
53    let varnames = validate("x-enum-varnames", varnames_raw);
54    let descriptions = validate("x-enum-descriptions", descriptions_raw);
55
56    if varnames.is_empty() && descriptions.is_empty() {
57        return None;
58    }
59    Some(EnumExtensions {
60        varnames,
61        descriptions,
62    })
63}
64
65#[derive(Debug, Clone)]
66pub struct SchemaAnalysis {
67    /// All schemas indexed by name
68    pub schemas: BTreeMap<String, AnalyzedSchema>,
69    /// Dependency graph for generation ordering
70    pub dependencies: DependencyGraph,
71    /// Detected patterns and transformations
72    pub patterns: DetectedPatterns,
73    /// OpenAPI operations and their request/response schemas
74    pub operations: BTreeMap<String, OperationInfo>,
75    /// Complete response contracts by emitted operation ID and response key.
76    /// Unlike `OperationInfo::response_schemas`, this retains responses with
77    /// no body as well as their selected JSON media type and SSE declaration.
78    pub operation_responses: BTreeMap<String, BTreeMap<String, OperationResponse>>,
79    /// Source operationId to emitted operation IDs. Duplicate or
80    /// Rust-identifier-colliding IDs are renamed during analysis; retaining
81    /// this mapping lets selector resolution report ambiguity or renaming.
82    pub operation_id_aliases: BTreeMap<String, Vec<String>>,
83    /// Optional crates the [`TypeMapper`] was asked to reference
84    /// during analysis (e.g. chrono when a `format: date-time` field
85    /// became `chrono::DateTime<Utc>`). The generator reads this to
86    /// decide which helper modules (e.g. `base64_serde`) to emit. Complete
87    /// dependency reporting is collected from retained emitted files so
88    /// pruned schemas cannot leak stale requirements.
89    ///
90    /// [`TypeMapper`]: crate::type_mapping::TypeMapper
91    pub used_type_features: crate::type_mapping::UsedFeatures,
92    /// Q2.6: per-schema vendor enum extensions
93    /// (`x-enum-varnames` / `x-enum-descriptions`). Populated during
94    /// analysis when a StringEnum / ExtensibleEnum schema declares
95    /// either extension; the generator uses these to override the
96    /// default heuristic variant names and emit per-variant doc
97    /// comments. Indexed by analyzed-schema name. Side-channel so we
98    /// don't have to touch every StringEnum constructor.
99    pub enum_extensions: BTreeMap<String, EnumExtensions>,
100    /// Raw, unpruned schema material used to build offline server validators.
101    /// This is deliberately independent of `schemas`, which model pruning may
102    /// mutate before server artifacts are emitted.
103    pub validation_context: ValidationContext,
104}
105
106impl SchemaType {
107    /// Whether the generator can render this type directly in a field or
108    /// element position.
109    ///
110    /// The other variants name something that must be generated as its own
111    /// item — a struct, an enum, a union — so a field can only hold them by
112    /// reference. Analysis hoists those and leaves a
113    /// [`SchemaType::Reference`]; anything that reaches the generator
114    /// un-hoisted is rendered as `serde_json::Value`, losing the type the
115    /// schema had. [`UntypedReason::inline_drop`] names those cases so the
116    /// census can count them.
117    pub fn renders_inline(&self) -> bool {
118        match self {
119            Self::Primitive { .. }
120            | Self::Reference { .. }
121            | Self::Array { .. }
122            | Self::Tuple { .. }
123            | Self::Untyped { .. } => true,
124            Self::Object { .. }
125            | Self::StringEnum { .. }
126            | Self::ExtensibleEnum { .. }
127            | Self::DiscriminatedUnion { .. }
128            | Self::Union { .. }
129            | Self::Composition { .. } => false,
130        }
131    }
132}
133
134impl UntypedReason {
135    /// The reason a non-inline-renderable type reaching a field position gets
136    /// dropped to `serde_json::Value`.
137    pub fn inline_drop(schema_type: &SchemaType) -> Option<Self> {
138        match schema_type {
139            SchemaType::Composition { .. } => Some(Self::InlineCompositionDropped),
140            SchemaType::Union { .. } | SchemaType::DiscriminatedUnion { .. } => {
141                Some(Self::InlineUnionDropped)
142            }
143            SchemaType::Object { .. } => Some(Self::InlineObjectDropped),
144            SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => {
145                Some(Self::InlineEnumDropped)
146            }
147            _ => None,
148        }
149    }
150}
151
152/// The schemas a generated type refers to.
153///
154/// Synthesized types — a hoisted property type, a named union — need these to
155/// be accurate, not merely non-empty: the dependency graph is what
156/// `detect_recursive_schemas` reads, and a cycle that runs through a
157/// synthesized type is invisible without them. Stripe's
158/// `Quote → QuotesResourceFromQuote → QuotesResourceFromQuoteQuote → Quote`
159/// compiled to an infinitely-sized enum until the middle link declared where it
160/// pointed.
161fn schema_type_dependencies(schema_type: &SchemaType) -> HashSet<String> {
162    let mut targets = HashSet::new();
163    collect_type_dependencies(schema_type, &mut targets, 0);
164    targets
165}
166
167fn collect_type_dependencies(
168    schema_type: &SchemaType,
169    targets: &mut HashSet<String>,
170    depth: usize,
171) {
172    if depth > UNTYPED_WALK_DEPTH {
173        return;
174    }
175    match schema_type {
176        SchemaType::Reference { target } => {
177            targets.insert(target.clone());
178        }
179        SchemaType::Array { item_type } => collect_type_dependencies(item_type, targets, depth + 1),
180        SchemaType::Tuple { element_types } => {
181            for element_type in element_types {
182                collect_type_dependencies(element_type, targets, depth + 1);
183            }
184        }
185        SchemaType::Object {
186            properties,
187            additional_properties,
188            ..
189        } => {
190            for property in properties.values() {
191                collect_type_dependencies(&property.schema_type, targets, depth + 1);
192            }
193            if let ObjectAdditionalProperties::Typed { value_type } = additional_properties {
194                collect_type_dependencies(value_type, targets, depth + 1);
195            }
196        }
197        SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => {
198            for variant in variants {
199                targets.insert(variant.target.clone());
200            }
201        }
202        SchemaType::DiscriminatedUnion { variants, .. } => {
203            for variant in variants {
204                targets.insert(variant.type_name.clone());
205            }
206        }
207        SchemaType::Primitive { .. }
208        | SchemaType::StringEnum { .. }
209        | SchemaType::ExtensibleEnum { .. }
210        | SchemaType::Untyped { .. } => {}
211    }
212}
213
214/// Convert any `serde_json::Value` still carried as a stringly-typed
215/// `Primitive` into [`SchemaType::Untyped`].
216///
217/// Several fallbacks build their type from a [`TypeMapper`] result rather than
218/// through the analyzer's helpers, so this runs over the finished IR as a net.
219/// Anything it catches is reported as [`UntypedReason::Unclassified`] — a
220/// visible gap in the taxonomy rather than a silently missing count.
221///
222/// [`TypeMapper`]: crate::type_mapping::TypeMapper
223fn normalize_untyped(schema_type: &mut SchemaType, depth: usize) {
224    if depth > UNTYPED_WALK_DEPTH {
225        return;
226    }
227    match schema_type {
228        SchemaType::Primitive { rust_type, .. } => {
229            let shape = match rust_type.as_str() {
230                "serde_json::Value" => Some(UntypedShape::Value),
231                "Vec<serde_json::Value>" => Some(UntypedShape::ValueArray),
232                _ => None,
233            };
234            if let Some(shape) = shape {
235                *schema_type = SchemaType::Untyped {
236                    shape,
237                    reason: UntypedReason::Unclassified,
238                };
239            }
240        }
241        SchemaType::Object {
242            properties,
243            additional_properties,
244            ..
245        } => {
246            for property in properties.values_mut() {
247                normalize_untyped(&mut property.schema_type, depth + 1);
248            }
249            if let ObjectAdditionalProperties::Typed { value_type } = additional_properties {
250                normalize_untyped(value_type, depth + 1);
251            }
252        }
253        SchemaType::Array { item_type } => normalize_untyped(item_type, depth + 1),
254        SchemaType::Tuple { element_types } => {
255            for element_type in element_types {
256                normalize_untyped(element_type, depth + 1);
257            }
258        }
259        SchemaType::Untyped { .. }
260        | SchemaType::StringEnum { .. }
261        | SchemaType::ExtensibleEnum { .. }
262        | SchemaType::DiscriminatedUnion { .. }
263        | SchemaType::Union { .. }
264        | SchemaType::Composition { .. }
265        | SchemaType::Reference { .. } => {}
266    }
267}
268
269impl SchemaAnalysis {
270    /// Every generated field that carries `serde_json::Value`, with the reason.
271    ///
272    /// Derived from the analyzed types rather than recorded as analysis runs,
273    /// so the count tracks generated output: a schema referenced by fifty
274    /// properties contributes fifty findings, and a pruned one contributes
275    /// none.
276    pub fn untyped_fields(&self) -> Vec<UntypedFinding> {
277        let mut findings = Vec::new();
278        for (name, schema) in &self.schemas {
279            collect_untyped(&schema.schema_type, name, &mut findings, 0);
280        }
281        findings.sort();
282        findings
283    }
284}
285
286/// Depth limit for the census walk. Generated types bottom out well before
287/// this; the bound only stops a cycle that slipped through analysis from
288/// hanging a diagnostic.
289const UNTYPED_WALK_DEPTH: usize = 32;
290
291fn collect_untyped(
292    schema_type: &SchemaType,
293    context: &str,
294    findings: &mut Vec<UntypedFinding>,
295    depth: usize,
296) {
297    if depth > UNTYPED_WALK_DEPTH {
298        return;
299    }
300    match schema_type {
301        SchemaType::Untyped { shape, reason } => findings.push(UntypedFinding {
302            context: context.to_string(),
303            shape: *shape,
304            reason: *reason,
305        }),
306        SchemaType::Object {
307            properties,
308            additional_properties,
309            ..
310        } => {
311            for (property_name, property) in properties {
312                let property_context = format!("{context}.{property_name}");
313                // A type the generator cannot render inline is dropped whole:
314                // count it here rather than descending into a type that will
315                // never reach the output.
316                if let Some(reason) = UntypedReason::inline_drop(&property.schema_type) {
317                    findings.push(UntypedFinding {
318                        context: property_context,
319                        shape: UntypedShape::Value,
320                        reason,
321                    });
322                    continue;
323                }
324                collect_untyped(
325                    &property.schema_type,
326                    &property_context,
327                    findings,
328                    depth + 1,
329                );
330            }
331            match additional_properties {
332                ObjectAdditionalProperties::Untyped => findings.push(UntypedFinding {
333                    context: format!("{context}.<additionalProperties>"),
334                    shape: UntypedShape::ValueMap,
335                    reason: UntypedReason::UntypedAdditionalProperties,
336                }),
337                ObjectAdditionalProperties::Typed { value_type } => collect_untyped(
338                    value_type,
339                    &format!("{context}.<additionalProperties>"),
340                    findings,
341                    depth + 1,
342                ),
343                ObjectAdditionalProperties::Forbidden => {}
344            }
345        }
346        SchemaType::Array { item_type } => {
347            let element_context = format!("{context}[]");
348            if let Some(reason) = UntypedReason::inline_drop(item_type) {
349                findings.push(UntypedFinding {
350                    context: element_context,
351                    shape: UntypedShape::Value,
352                    reason,
353                });
354            } else {
355                collect_untyped(item_type, &element_context, findings, depth + 1);
356            }
357        }
358        SchemaType::Tuple { element_types } => {
359            for (index, element_type) in element_types.iter().enumerate() {
360                collect_untyped(
361                    element_type,
362                    &format!("{context}[{index}]"),
363                    findings,
364                    depth + 1,
365                );
366            }
367        }
368        // A union branch that mapped to an untyped Rust type is carried as a
369        // variant target string, so it is recognized by name here.
370        SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => {
371            for (index, variant) in variants.iter().enumerate() {
372                if let Some(shape) = untyped_shape_of(&variant.target) {
373                    findings.push(UntypedFinding {
374                        context: format!("{context}|{index}"),
375                        shape,
376                        reason: UntypedReason::UntypedUnionBranch,
377                    });
378                }
379            }
380        }
381        SchemaType::Primitive { .. }
382        | SchemaType::StringEnum { .. }
383        | SchemaType::ExtensibleEnum { .. }
384        | SchemaType::DiscriminatedUnion { .. }
385        | SchemaType::Reference { .. } => {}
386    }
387}
388
389/// The untyped shape a generated Rust type name denotes, if any.
390fn untyped_shape_of(rust_type: &str) -> Option<UntypedShape> {
391    match rust_type {
392        "serde_json::Value" => Some(UntypedShape::Value),
393        "Vec<serde_json::Value>" => Some(UntypedShape::ValueArray),
394        _ => None,
395    }
396}
397
398/// One generated field (or type) that carries `serde_json::Value` instead of a
399/// generated Rust type.
400#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
401pub struct UntypedFinding {
402    /// Where it surfaced, as far as analysis knows: usually
403    /// `Schema.property`, or a synthesized operation type.
404    pub context: String,
405    /// The shape the generator will emit.
406    pub shape: UntypedShape,
407    /// Why the schema produced no better type.
408    pub reason: UntypedReason,
409}
410
411/// The generated shape carrying the untyped payload.
412#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
413#[serde(rename_all = "snake_case")]
414pub enum UntypedShape {
415    /// `serde_json::Value`
416    Value,
417    /// `Vec<serde_json::Value>`
418    ValueArray,
419    /// `BTreeMap<String, serde_json::Value>`
420    ValueMap,
421}
422
423/// Why a schema produced an untyped value.
424///
425/// The split that matters is [`UntypedReason::verdict`]: a schema that says
426/// "any JSON" has no better Rust type and is generated correctly, while a
427/// schema that carried type information the generator dropped is a defect with
428/// a fix. Counting the two together would make the corpus look worse than it is
429/// and hide which cases are worth work.
430#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
431#[serde(rename_all = "kebab-case")]
432pub enum UntypedReason {
433    /// `{}`, `true`, or a schema with no constraining keyword at all: the spec
434    /// declares any JSON value.
435    AnySchema,
436    /// `type: object` with no `properties` and no typed `additionalProperties`:
437    /// an object of unknown shape.
438    OpaqueObject,
439    /// `additionalProperties: true` or absent, so map values are unconstrained.
440    UntypedAdditionalProperties,
441    /// `type: array` with no `items` at all.
442    ArrayWithoutItems,
443    /// Positional items that permit extra elements of any type (issue #62,
444    /// tier 3), so neither a tuple nor a `Vec<T>` is sound.
445    OpenPositionalItems,
446    /// A union (`oneOf`/`anyOf`) whose branches did not reduce to one
447    /// generated Rust type.
448    UnrepresentableUnion,
449    /// An `allOf` composition that could not be merged into a struct.
450    UnrepresentableComposition,
451    /// The schema declared a type keyword the generator has no mapping for.
452    UnsupportedTypeKeyword,
453    /// A `$ref` that analysis could not resolve to a generated schema.
454    UnresolvedReference,
455    /// An `allOf` composition sitting in a field position. Analysis did not
456    /// merge or hoist it, and a field cannot hold one, so the generator emits
457    /// `serde_json::Value` — dropping a type the schema fully described. A
458    /// single-branch `allOf` around a scalar is the common shape.
459    InlineCompositionDropped,
460    /// A union in a field position that was never hoisted to a named enum.
461    InlineUnionDropped,
462    /// An inline object in a field position that was never hoisted to a struct.
463    InlineObjectDropped,
464    /// An inline enum in a field position that was never hoisted.
465    InlineEnumDropped,
466    /// A `oneOf`/`anyOf` branch that mapped to an untyped value, so the
467    /// generated union carries a `serde_json::Value` variant. Whether that is
468    /// faithful depends on the branch, which the analyzed type no longer says.
469    UntypedUnionBranch,
470    /// Reached a fallback that has not been classified yet. Every one of these
471    /// is a gap in this taxonomy, not in the generator.
472    Unclassified,
473}
474
475impl UntypedReason {
476    /// Whether the untyped output is the honest reading of the schema, or a
477    /// case where the generator can do better.
478    pub fn verdict(self) -> UntypedVerdict {
479        match self {
480            // The spec genuinely declares an unconstrained value.
481            Self::AnySchema | Self::OpaqueObject | Self::UntypedAdditionalProperties => {
482                UntypedVerdict::Faithful
483            }
484            // An array with no `items` says nothing about elements, and open
485            // positional items permit extras of any type: both are the spec's
486            // choice, not a dropped constraint.
487            Self::ArrayWithoutItems | Self::OpenPositionalItems => UntypedVerdict::Faithful,
488            // The schema described a type that the generator then dropped
489            // because nothing hoisted it out of the field position.
490            Self::InlineCompositionDropped
491            | Self::InlineUnionDropped
492            | Self::InlineObjectDropped
493            | Self::InlineEnumDropped => UntypedVerdict::Recoverable,
494            // These carried type information that did not survive analysis.
495            Self::UnrepresentableUnion
496            | Self::UnrepresentableComposition
497            | Self::UnsupportedTypeKeyword
498            | Self::UnresolvedReference => UntypedVerdict::Recoverable,
499            Self::UntypedUnionBranch | Self::Unclassified => UntypedVerdict::Unknown,
500        }
501    }
502}
503
504/// Whether an untyped output is worth working on.
505#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
506#[serde(rename_all = "snake_case")]
507pub enum UntypedVerdict {
508    /// The schema declares an unconstrained value; `serde_json::Value` is correct.
509    Faithful,
510    /// The schema carried type information the generator dropped.
511    Recoverable,
512    /// Not yet classified.
513    Unknown,
514}
515
516/// Server-relevant semantics of one OpenAPI Response Object.
517#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
518pub struct OperationResponse {
519    /// Generated Rust body type for the preferred JSON-compatible content.
520    pub schema_name: Option<String>,
521    /// Exact declared JSON-compatible media type selected for `schema_name`.
522    pub media_type: Option<String>,
523    /// Preferred buffered response representation for this status. JSON keeps
524    /// its generated schema name; text and binary bodies are represented
525    /// directly by the generated client/server runtime types.
526    pub body: Option<OperationResponseBody>,
527    /// Whether this response also declares `text/event-stream` content.
528    pub supports_streaming: bool,
529    /// Whether the Response Object declared at least one content entry.
530    pub has_content: bool,
531    /// Declared response media types the server generator cannot emit.
532    pub unsupported_media_types: Vec<String>,
533}
534
535/// Buffered response representation selected from one OpenAPI Response Object.
536/// SSE remains orthogonal on [`OperationResponse::supports_streaming`] because
537/// a response may advertise both a buffered JSON representation and an event
538/// stream.
539#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
540#[serde(tag = "kind", rename_all = "snake_case")]
541pub enum OperationResponseBody {
542    Json {
543        schema_name: String,
544        media_type: String,
545    },
546    Text {
547        media_type: String,
548    },
549    Binary {
550        media_type: String,
551        wildcard: bool,
552    },
553}
554
555#[derive(Debug, Clone, Default)]
556pub struct ValidationContext {
557    pub openapi_version: String,
558    pub json_schema_dialect: Option<String>,
559    pub component_schemas: BTreeMap<String, Value>,
560}
561
562/// Q2.6 — vendor extensions describing a string enum's variant
563/// names and per-variant descriptions. Length must match the
564/// schema's `enum` array; mismatched extensions are dropped at
565/// analysis time with a warning.
566#[derive(Debug, Clone, Default)]
567pub struct EnumExtensions {
568    /// `x-enum-varnames`: Rust-friendly variant identifiers per
569    /// enum value, in the same order as the spec's `enum` array.
570    /// When present and length matches, the generator uses these
571    /// instead of its default PascalCase heuristic.
572    pub varnames: Vec<String>,
573    /// `x-enum-descriptions`: one doc-comment per enum value.
574    pub descriptions: Vec<String>,
575}
576
577#[derive(Debug, Clone)]
578pub struct AnalyzedSchema {
579    pub name: String,
580    pub original: Value,
581    pub schema_type: SchemaType,
582    pub dependencies: HashSet<String>,
583    pub nullable: bool,
584    pub description: Option<String>,
585    pub default: Option<serde_json::Value>,
586}
587
588#[derive(Debug, Clone)]
589pub enum SchemaType {
590    /// Simple primitive type. `serde_with` carries an optional
591    /// `#[serde(with = "<path>")]` codec hint produced by the
592    /// TypeMapper for typed scalars (e.g. `format: byte` →
593    /// `Vec<u8>` + `base64_serde`); the generator wraps this in a
594    /// field-level `with = ...` attribute.
595    Primitive {
596        rust_type: String,
597        serde_with: Option<String>,
598    },
599    /// Object with properties
600    Object {
601        properties: BTreeMap<String, PropertyInfo>,
602        required: HashSet<String>,
603        additional_properties: ObjectAdditionalProperties,
604        /// A union the schema declares *alongside* its own properties —
605        /// `{properties: {...}, anyOf: [...]}` — meaning "these fields, and
606        /// one of these shapes". Held in a `#[serde(flatten)]` field so both
607        /// halves survive; `None` for a plain object.
608        variant: Option<SchemaRef>,
609    },
610    /// Discriminated union (oneOf + discriminator)
611    DiscriminatedUnion {
612        discriminator_field: String,
613        variants: Vec<UnionVariant>,
614    },
615    /// Simple union (anyOf without discriminator)
616    Union { variants: Vec<SchemaRef> },
617    /// Array type
618    Array { item_type: Box<SchemaType> },
619    /// Fixed-arity array — one schema per position, no extras — rendered as a
620    /// Rust tuple. Only emitted when the spec proves the length (see
621    /// `SchemaDetails::positional_items_are_exact`); an open `prefixItems`
622    /// stays an `Array`, because serde would reject the extra elements the
623    /// spec allows.
624    Tuple { element_types: Vec<SchemaType> },
625    /// String enum
626    StringEnum { values: Vec<String> },
627    /// Extensible enum with known values and custom variant
628    ExtensibleEnum { known_values: Vec<String> },
629    /// Schema composition (allOf)
630    Composition { schemas: Vec<SchemaRef> },
631    /// Reference to another schema
632    Reference { target: String },
633    /// A value the generator could not type, rendered as `serde_json::Value`.
634    ///
635    /// The reason travels with the type rather than in a side table, so the
636    /// census counts what is actually generated: a schema analyzed once but
637    /// referenced by fifty properties yields fifty untyped fields, and one that
638    /// is pruned yields none.
639    Untyped {
640        shape: UntypedShape,
641        reason: UntypedReason,
642    },
643}
644
645/// How an Object handles `additionalProperties`. Q2.3 split the
646/// pre-existing `bool` into a three-way enum so the generator can
647/// emit a typed `BTreeMap<String, T>` when the spec provides a
648/// value-type schema instead of degrading to `serde_json::Value`.
649#[derive(Debug, Clone)]
650pub enum ObjectAdditionalProperties {
651    /// `additionalProperties: false` or absent — extra keys are
652    /// rejected and no extra field is emitted.
653    Forbidden,
654    /// `additionalProperties: true` — extra keys captured as
655    /// `BTreeMap<String, serde_json::Value>`.
656    Untyped,
657    /// `additionalProperties: <schema>` — extra keys captured as
658    /// `BTreeMap<String, T>` where T comes from the schema.
659    Typed { value_type: Box<SchemaType> },
660}
661
662impl ObjectAdditionalProperties {
663    /// True when extra keys are accepted (regardless of typing).
664    /// Used by callers that only care whether the field exists.
665    pub fn is_open(&self) -> bool {
666        !matches!(self, Self::Forbidden)
667    }
668}
669
670#[derive(Debug, Clone)]
671pub struct PropertyInfo {
672    pub schema_type: SchemaType,
673    pub nullable: bool,
674    pub description: Option<String>,
675    pub default: Option<serde_json::Value>,
676    pub serde_attrs: Vec<String>,
677    /// Q2.4: OpenAPI constraint annotations captured from the
678    /// property schema. Surfaced by the generator as `/// Constraint:
679    /// …` doc lines and/or `#[validate(...)]` attributes depending on
680    /// `[generator.types.constraints] mode`.
681    pub constraints: PropertyConstraints,
682}
683
684/// Q2.4 — per-property OpenAPI constraint annotations
685/// (`minimum`/`maximum`/`minLength`/`maxLength`/`pattern`/etc.).
686/// Populated during analysis from `SchemaDetails`; consumed by the
687/// generator to emit doc comments and/or `#[validate(...)]` attrs.
688#[derive(Debug, Clone, Default)]
689pub struct PropertyConstraints {
690    pub minimum: Option<f64>,
691    pub maximum: Option<f64>,
692    pub exclusive_minimum: Option<f64>,
693    pub exclusive_maximum: Option<f64>,
694    pub multiple_of: Option<f64>,
695    pub min_length: Option<u64>,
696    pub max_length: Option<u64>,
697    pub pattern: Option<String>,
698    pub min_items: Option<u64>,
699    pub max_items: Option<u64>,
700    pub unique_items: Option<bool>,
701}
702
703impl PropertyConstraints {
704    pub fn is_empty(&self) -> bool {
705        self.minimum.is_none()
706            && self.maximum.is_none()
707            && self.exclusive_minimum.is_none()
708            && self.exclusive_maximum.is_none()
709            && self.multiple_of.is_none()
710            && self.min_length.is_none()
711            && self.max_length.is_none()
712            && self.pattern.is_none()
713            && self.min_items.is_none()
714            && self.max_items.is_none()
715            && self.unique_items.is_none()
716    }
717
718    /// Capture the constraint-related fields off a `SchemaDetails`.
719    /// Exclusive bounds in OpenAPI 3.1 are numeric (`exclusiveMinimum:
720    /// 5`); we map the OAS-3.0 boolean flag form by leaving the
721    /// exclusive field unset and letting `minimum`/`maximum` carry it.
722    pub fn from_schema_details(details: &crate::openapi::SchemaDetails) -> Self {
723        use crate::openapi::ExclusiveBound;
724        let exclusive_minimum = match &details.exclusive_minimum {
725            Some(ExclusiveBound::Number(v)) => Some(*v),
726            _ => None,
727        };
728        let exclusive_maximum = match &details.exclusive_maximum {
729            Some(ExclusiveBound::Number(v)) => Some(*v),
730            _ => None,
731        };
732        Self {
733            minimum: details.minimum,
734            maximum: details.maximum,
735            exclusive_minimum,
736            exclusive_maximum,
737            multiple_of: details.multiple_of,
738            min_length: details.min_length,
739            max_length: details.max_length,
740            pattern: details.pattern.clone(),
741            min_items: details.min_items,
742            max_items: details.max_items,
743            unique_items: details.unique_items,
744        }
745    }
746}
747
748#[derive(Debug, Clone)]
749pub struct UnionVariant {
750    pub rust_name: String,
751    pub type_name: String,
752    pub discriminator_value: String,
753    pub schema_ref: String,
754}
755
756#[derive(Debug, Clone)]
757pub struct SchemaRef {
758    pub target: String,
759    pub nullable: bool,
760}
761
762#[derive(Debug, Clone)]
763pub struct DependencyGraph {
764    pub edges: BTreeMap<String, HashSet<String>>,
765    /// Set of schemas that have recursive dependencies
766    pub recursive_schemas: HashSet<String>,
767}
768
769#[derive(Debug, Clone)]
770pub struct DetectedPatterns {
771    /// Schemas that should use tagged enums (discriminated unions)
772    pub tagged_enum_schemas: HashSet<String>,
773    /// Schemas that should use untagged enums (simple unions)
774    pub untagged_enum_schemas: HashSet<String>,
775    /// Auto-detected type mappings for discriminated unions
776    pub type_mappings: BTreeMap<String, BTreeMap<String, String>>,
777}
778
779/// Information about an OpenAPI operation
780#[derive(Debug, Clone, Default, serde::Serialize)]
781pub struct OperationInfo {
782    /// Operation ID
783    pub operation_id: String,
784    /// HTTP method (GET, POST, etc.)
785    pub method: String,
786    /// Path template
787    pub path: String,
788    /// Short summary from OpenAPI spec
789    pub summary: Option<String>,
790    /// Longer description from OpenAPI spec
791    pub description: Option<String>,
792    /// Request body content type and schema (if any)
793    pub request_body: Option<RequestBodyContent>,
794    /// Whether `requestBody.required` was true. Drives whether the generated
795    /// method takes a `Body` argument or `Option<Body>` (T11).
796    pub request_body_required: bool,
797    /// Response schemas by status code
798    pub response_schemas: BTreeMap<String, String>,
799    /// Parameters (path, query, header)
800    pub parameters: Vec<ParameterInfo>,
801    /// Whether this operation supports streaming
802    pub supports_streaming: bool,
803    /// Stream parameter name if applicable
804    pub stream_parameter: Option<String>,
805    /// Tags declared on the operation. Empty when the spec sets none.
806    /// Used by the server codegen selector grammar (e.g. `tag:Chat`)
807    /// and by `openapi-to-rust server list` for grouping.
808    pub tags: Vec<String>,
809}
810
811/// Content type and schema for a request body
812#[derive(Debug, Clone, serde::Serialize)]
813#[serde(tag = "kind")]
814pub enum RequestBodyContent {
815    Json {
816        schema_name: String,
817        media_type: String,
818        #[serde(skip)]
819        validation_schema: Value,
820    },
821    FormUrlEncoded {
822        schema_name: String,
823        media_type: String,
824        #[serde(skip)]
825        validation_schema: Value,
826    },
827    Multipart {
828        schema_name: String,
829        media_type: String,
830        #[serde(skip)]
831        validation_schema: Value,
832    },
833    OctetStream {
834        media_type: String,
835    },
836    Binary {
837        media_type: String,
838    },
839    TextPlain {
840        media_type: String,
841    },
842    /// A declared request media type without a schema. Client generation
843    /// preserves its historical no-body signature, while server generation
844    /// rejects the operation because there is no contract to validate.
845    SchemaLess {
846        media_type: String,
847    },
848    Unsupported {
849        media_types: Vec<String>,
850    },
851}
852
853impl RequestBodyContent {
854    /// Get the schema name if this content type has one
855    pub fn schema_name(&self) -> Option<&str> {
856        match self {
857            Self::Json { schema_name, .. }
858            | Self::FormUrlEncoded { schema_name, .. }
859            | Self::Multipart { schema_name, .. } => Some(schema_name),
860            Self::OctetStream { .. }
861            | Self::Binary { .. }
862            | Self::TextPlain { .. }
863            | Self::SchemaLess { .. }
864            | Self::Unsupported { .. } => None,
865        }
866    }
867}
868
869/// Compute the disambiguation-base for a parameter name. Mirrors
870/// `ClientGenerator::sanitize_param_name` so analysis-time uniqueness
871/// decisions and codegen-time emission agree on the final ident.
872fn base_param_ident(name: &str) -> String {
873    use heck::ToSnakeCase;
874    let suffix = if name.ends_with("<=") {
875        "_lte"
876    } else if name.ends_with(">=") {
877        "_gte"
878    } else if name.ends_with('<') {
879        "_lt"
880    } else if name.ends_with('>') {
881        "_gt"
882    } else {
883        ""
884    };
885    let stripped = name.trim_end_matches(['<', '>', '=']);
886    let mut snake = stripped.to_snake_case();
887    if snake.is_empty() {
888        snake.push_str("parameter");
889    } else if snake.starts_with(|character: char| character.is_ascii_digit()) {
890        snake.insert(0, '_');
891    }
892    snake.push_str(suffix);
893    snake
894}
895
896/// Information about an operation parameter
897#[derive(Debug, Clone, serde::Serialize)]
898pub struct ParameterInfo {
899    /// Parameter name
900    pub name: String,
901    /// Parameter location (path, query, header, cookie)
902    pub location: String,
903    /// Whether the parameter is required
904    pub required: bool,
905    /// Schema reference for the parameter type
906    pub schema_ref: Option<String>,
907    /// Rust type for this parameter
908    pub rust_type: String,
909    /// Description from OpenAPI spec
910    pub description: Option<String>,
911    /// String enum values when the parameter's inline schema is a string with
912    /// `enum` or `const`. When set, `rust_type` is the synthetic enum type
913    /// name (e.g. `GetItemTheConstant`) and the client generator emits an
914    /// inline enum so the parameter is constrained to the declared values.
915    /// See issue #10 follow-up.
916    #[serde(skip_serializing_if = "Option::is_none")]
917    pub enum_values: Option<Vec<String>>,
918    /// `x-enum-varnames` declared on the parameter's inline enum schema, when
919    /// present and the same length as `enum_values`. Schema-level enums already
920    /// honor this vendor extension through `SchemaAnalysis::enum_extensions`;
921    /// parameter enums are inline and have no analyzed-schema name to key on,
922    /// so their names ride along here instead.
923    #[serde(skip_serializing_if = "Option::is_none")]
924    pub enum_varnames: Option<Vec<String>>,
925    /// Disambiguated Rust ident assigned by the analyzer at the operation
926    /// scope. When two parameters in the same operation sanitize to the same
927    /// snake_case name (e.g. `exclude_ids` + `exclude-ids` in vercel,
928    /// `StartTime` + `StartTime>` in twilio), the analyzer suffixes
929    /// later occurrences with `_2`, `_3`, … so the codegen function
930    /// signature and body don't reuse the same binding.
931    /// Empty/none = use sanitize from `name`.
932    #[serde(skip_serializing_if = "Option::is_none")]
933    pub rust_ident: Option<String>,
934    /// Wire serialization for object/array query parameters, decided from
935    /// the parameter's `style`/`explode` and schema shape (T14, GH #27).
936    /// `None` = plain single `name=value` pair (scalars, string enums, and
937    /// the ordinary scalar `name=value` representation. Unsupported complex
938    /// shapes carry an explicit [`QuerySerialization::Unsupported`] reason so
939    /// downstream client/server generators cannot silently drift.
940    /// For the object modes, `schema_ref` holds the struct type
941    /// generated/resolved for the object schema.
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub query_serialization: Option<QuerySerialization>,
944    /// Original parameter schema retained for request validation. This is not
945    /// exposed by serialized operation listings.
946    #[serde(skip)]
947    pub validation_schema: Option<Value>,
948}
949
950/// How generated clients serialize and generated servers extract an object-
951/// or array-schema query parameter.
952#[derive(Debug, Clone, PartialEq, serde::Serialize)]
953pub enum QuerySerialization {
954    /// style=form + explode=true object (the OAS 3.x defaults for query):
955    /// each property is its own pair — `?color=red&size=big`. The parameter
956    /// name never appears in the query string (RFC 6570 form-explosion).
957    FormExplodedObject,
958    /// AWS query-protocol form explosion for an object containing arrays:
959    /// `Parameter.Prop.1=value` or `Parameter.Prop.1.Leaf=value`. Unlike
960    /// ordinary RFC 6570 form explosion, AWS service models retain the outer
961    /// parameter wire name; client and server generation intentionally mirror
962    /// that protocol-specific representation.
963    FormExplodedNestedObject {
964        properties: Vec<QueryStructProperty>,
965    },
966    /// style=form + explode=false object: one comma-joined key,value list —
967    /// `?filter=color,red,size,big`.
968    FormObject,
969    /// style=deepObject (explode=true) object: bracketed keys —
970    /// `?filter[color]=red`.
971    DeepObject,
972    /// style=form + explode=true array: repeated pairs — `?tags=a&tags=b`.
973    /// Parameter typed `Vec<item_type>`.
974    FormExplodedArray { item_type: ArrayItemType },
975    /// style=form + explode=false array: one comma-joined pair —
976    /// `?tags=a,b,c`. Parameter typed `Vec<item_type>`.
977    FormArray { item_type: ArrayItemType },
978    /// Header `style=simple, explode=false` array: one physical header value
979    /// containing comma-separated scalar items.
980    SimpleHeaderArray { item_type: ArrayItemType },
981    /// A complex query shape whose wire representation is undefined by
982    /// OpenAPI or not implemented symmetrically. Clients retain the explicit
983    /// opaque-string escape hatch; server generation rejects it with this
984    /// actionable reason instead of emitting an impossible extractor.
985    Unsupported { reason: String },
986}
987
988/// Item type of a typed array query parameter. The two variants need
989/// different handling in codegen: scalars are already Rust type strings
990/// (possibly paths like `rust_decimal::Decimal` from `[type_mappings]`),
991/// while schema refs are raw *schema names* that must run through
992/// `to_rust_type_name` sanitization (cloudflare:
993/// `resource-sharing_resource_type`).
994#[derive(Debug, Clone, PartialEq, serde::Serialize)]
995pub enum ArrayItemType {
996    /// A Rust scalar type string from the TypeMapper (`String`, `i32`, …).
997    Scalar(String),
998    /// The schema name of a referenced scalar alias or string enum.
999    SchemaRef(String),
1000    /// The schema name of a referenced *flat* structure — every property is
1001    /// scalar. Serialized AWS query-protocol style as
1002    /// `param.N.Prop=value` per item (e.g. `Tags.1.Key=k&Tags.1.Value=v`).
1003    /// Carries the wire property names so client and server emit identical
1004    /// keys without re-resolving the schema.
1005    FlatStructRef {
1006        schema_name: String,
1007        properties: Vec<QueryStructProperty>,
1008    },
1009    /// A referenced structure with scalar properties plus arrays whose items
1010    /// are scalar or flat structures. This is the deepest unambiguous shape
1011    /// used by AWS query protocols (`param.N.Prop.M.Leaf=value`).
1012    NestedStructRef {
1013        schema_name: String,
1014        properties: Vec<QueryStructProperty>,
1015    },
1016}
1017
1018#[derive(Debug, Clone, PartialEq, serde::Serialize)]
1019pub struct QueryStructProperty {
1020    pub wire_name: String,
1021    pub required: bool,
1022    pub value_type: QueryStructPropertyType,
1023}
1024
1025#[derive(Debug, Clone, PartialEq, serde::Serialize)]
1026pub enum QueryStructPropertyType {
1027    Scalar(QueryScalarType),
1028    Array {
1029        item_type: ArrayItemType,
1030    },
1031    Object {
1032        properties: Vec<QueryStructProperty>,
1033    },
1034}
1035
1036#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1037pub enum QueryScalarType {
1038    String,
1039    Integer,
1040    Number,
1041    Boolean,
1042}
1043
1044impl Default for DependencyGraph {
1045    fn default() -> Self {
1046        Self::new()
1047    }
1048}
1049
1050impl DependencyGraph {
1051    pub fn new() -> Self {
1052        Self {
1053            edges: BTreeMap::new(),
1054            recursive_schemas: HashSet::new(),
1055        }
1056    }
1057
1058    pub fn add_dependency(&mut self, from: String, to: String) {
1059        self.edges.entry(from).or_default().insert(to);
1060    }
1061
1062    /// Get topological sort order for generation
1063    pub fn topological_sort(&mut self) -> Result<Vec<String>> {
1064        // First, detect and handle recursive dependencies
1065        self.detect_recursive_schemas();
1066
1067        // Create a temporary graph without self-referencing edges for sorting
1068        let mut temp_edges = self.edges.clone();
1069        for (schema, deps) in &mut temp_edges {
1070            deps.remove(schema); // Remove self-references
1071        }
1072
1073        let mut visited = HashSet::new();
1074        let mut temp_visited = HashSet::new();
1075        let mut result = Vec::new();
1076
1077        // Visit all nodes using the temporary graph in sorted order for deterministic output
1078        let mut all_nodes: Vec<_> = temp_edges.keys().collect();
1079        all_nodes.sort();
1080        for node in all_nodes {
1081            if !visited.contains(node) {
1082                self.visit_node_recursive(
1083                    node,
1084                    &temp_edges,
1085                    &mut visited,
1086                    &mut temp_visited,
1087                    &mut result,
1088                )?;
1089            }
1090        }
1091
1092        result.reverse();
1093        Ok(result)
1094    }
1095
1096    fn detect_recursive_schemas(&mut self) {
1097        for (schema, deps) in &self.edges {
1098            if deps.contains(schema) {
1099                // Direct self-reference
1100                self.recursive_schemas.insert(schema.clone());
1101            } else {
1102                // Check for indirect cycles
1103                if self.has_cycle_from(schema, schema, &mut HashSet::new()) {
1104                    self.recursive_schemas.insert(schema.clone());
1105                }
1106            }
1107        }
1108
1109        // Also detect mutual recursion (like GraphNode <-> GraphEdge)
1110        for (schema, deps) in &self.edges {
1111            for dep in deps {
1112                if let Some(dep_deps) = self.edges.get(dep) {
1113                    if dep_deps.contains(schema) {
1114                        // Mutual recursion detected
1115                        self.recursive_schemas.insert(schema.clone());
1116                        self.recursive_schemas.insert(dep.clone());
1117                    }
1118                }
1119            }
1120        }
1121    }
1122
1123    fn has_cycle_from(&self, start: &str, current: &str, visited: &mut HashSet<String>) -> bool {
1124        if visited.contains(current) {
1125            return false; // Already checked this path
1126        }
1127
1128        visited.insert(current.to_string());
1129
1130        if let Some(deps) = self.edges.get(current) {
1131            for dep in deps {
1132                if dep == start {
1133                    return true; // Found cycle back to start
1134                }
1135                if self.has_cycle_from(start, dep, visited) {
1136                    return true;
1137                }
1138            }
1139        }
1140
1141        false
1142    }
1143
1144    #[allow(clippy::only_used_in_recursion)]
1145    fn visit_node_recursive(
1146        &self,
1147        node: &str,
1148        temp_edges: &BTreeMap<String, HashSet<String>>,
1149        visited: &mut HashSet<String>,
1150        temp_visited: &mut HashSet<String>,
1151        result: &mut Vec<String>,
1152    ) -> Result<()> {
1153        if temp_visited.contains(node) {
1154            // This should not happen with cycle-free temp graph, but just in case
1155            return Ok(());
1156        }
1157
1158        if visited.contains(node) {
1159            return Ok(());
1160        }
1161
1162        temp_visited.insert(node.to_string());
1163
1164        if let Some(dependencies) = temp_edges.get(node) {
1165            // Sort dependencies for deterministic topological order
1166            let mut sorted_deps: Vec<_> = dependencies.iter().collect();
1167            sorted_deps.sort();
1168            for dep in sorted_deps {
1169                self.visit_node_recursive(dep, temp_edges, visited, temp_visited, result)?;
1170            }
1171        }
1172
1173        temp_visited.remove(node);
1174        visited.insert(node.to_string());
1175        result.push(node.to_string());
1176
1177        Ok(())
1178    }
1179}
1180
1181/// Merge schema extension files into the main OpenAPI specification
1182/// Uses simple recursive JSON object merging
1183pub fn merge_schema_extensions(
1184    main_spec: Value,
1185    extension_paths: &[impl AsRef<Path>],
1186) -> Result<Value> {
1187    let mut result = main_spec;
1188
1189    for path in extension_paths {
1190        let extension = load_extension_file(path.as_ref())?;
1191        result = merge_json_objects_with_replacements(result, extension)?;
1192    }
1193
1194    Ok(result)
1195}
1196
1197/// AWS-style specs append query markers to their path templates
1198/// (`/tags/{resourceArn}#tagKeys`, `/2015-02-01/resource-tags/{ResourceId}#tagKeys`).
1199/// The fragment is not part of the route — those values are declared as
1200/// ordinary query parameters on the operation — so strip it before the path
1201/// reaches route generation. Axum (and every HTTP router) matches on the path
1202/// component only.
1203fn normalize_operation_path(path: &str) -> String {
1204    match path.split_once('#') {
1205        Some((route, _fragment)) if route.starts_with('/') => route.to_string(),
1206        _ => path.to_string(),
1207    }
1208}
1209
1210/// See through an `allOf: [$ref, {annotation}]` wrapper around a schema, the
1211/// same shape `analyze_all_of` treats as a type alias. Returns the sole
1212/// reference target's schema when every other member is annotation-only;
1213/// otherwise the schema itself.
1214fn unwrap_annotation_allof(schema: &crate::openapi::Schema) -> &crate::openapi::Schema {
1215    let crate::openapi::Schema::AllOf { all_of, .. } = schema else {
1216        return schema;
1217    };
1218    let mut references = all_of.iter().filter(|s| s.reference().is_some());
1219    let (Some(first), None) = (references.next(), references.next()) else {
1220        return schema;
1221    };
1222    let others_annotation_only = all_of.iter().all(|member| {
1223        if member.reference().is_some() {
1224            return true;
1225        }
1226        serde_json::to_value(member)
1227            .ok()
1228            .and_then(|value| value.as_object().cloned())
1229            .is_some_and(|object| {
1230                object.keys().all(|key| {
1231                    matches!(
1232                        key.as_str(),
1233                        "title"
1234                            | "description"
1235                            | "deprecated"
1236                            | "readOnly"
1237                            | "writeOnly"
1238                            | "examples"
1239                            | "example"
1240                            | "externalDocs"
1241                            | "xml"
1242                            | "$comment"
1243                    ) || key.starts_with("x-")
1244                })
1245            })
1246    });
1247    if others_annotation_only {
1248        first
1249    } else {
1250        schema
1251    }
1252}
1253
1254/// Load an extension file and parse it into the JSON representation used by
1255/// the analyzer. YAML extensions follow the same conversion policy as YAML
1256/// OpenAPI documents; every other extension is parsed as JSON.
1257fn load_extension_file(path: &Path) -> Result<Value> {
1258    let content = std::fs::read_to_string(path).map_err(|e| GeneratorError::FileError {
1259        message: format!("Failed to read file {}: {}", path.display(), e),
1260    })?;
1261
1262    let is_yaml = path
1263        .extension()
1264        .and_then(|extension| extension.to_str())
1265        .is_some_and(|extension| {
1266            extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml")
1267        });
1268
1269    if is_yaml {
1270        crate::spec_source::yaml_to_json_value(&content).map_err(|error| {
1271            GeneratorError::FileError {
1272                message: format!(
1273                    "Failed to parse schema extension {} as YAML: {}",
1274                    path.display(),
1275                    error
1276                ),
1277            }
1278        })
1279    } else {
1280        serde_json::from_str(&content).map_err(|error| GeneratorError::FileError {
1281            message: format!(
1282                "Failed to parse schema extension {} as JSON: {}",
1283                path.display(),
1284                error
1285            ),
1286        })
1287    }
1288}
1289
1290/// Merge JSON objects with explicit replacement support
1291fn merge_json_objects_with_replacements(main: Value, extension: Value) -> Result<Value> {
1292    // Extract replacement rules from the extension
1293    let replacements = extract_replacement_rules(&extension);
1294
1295    // Perform the merge with replacement awareness
1296    Ok(merge_json_objects_with_rules(
1297        main,
1298        extension,
1299        &replacements,
1300    ))
1301}
1302
1303/// Extract x-replacements rules from extension
1304fn extract_replacement_rules(
1305    extension: &Value,
1306) -> std::collections::HashMap<String, (String, String)> {
1307    let mut rules = std::collections::HashMap::new();
1308
1309    if let Some(x_replacements) = extension.get("x-replacements") {
1310        if let Some(x_replacements_obj) = x_replacements.as_object() {
1311            for (schema_name, replacement_rule) in x_replacements_obj {
1312                if let Some(rule_obj) = replacement_rule.as_object() {
1313                    if let (Some(replace), Some(with)) = (
1314                        rule_obj.get("replace").and_then(|v| v.as_str()),
1315                        rule_obj.get("with").and_then(|v| v.as_str()),
1316                    ) {
1317                        rules.insert(schema_name.clone(), (replace.to_string(), with.to_string()));
1318                        // println!("📋 Replacement rule: In {}, replace {} with {}", schema_name, replace, with);
1319                    }
1320                }
1321            }
1322        }
1323    }
1324
1325    rules
1326}
1327
1328/// Check if a variant should be replaced based on explicit replacement rules
1329fn should_replace_variant(
1330    schema_name: &str,
1331    extension_refs: &[String],
1332    replacements: &std::collections::HashMap<String, (String, String)>,
1333) -> bool {
1334    // Check all replacement rules
1335    for (replace_schema, with_schema) in replacements.values() {
1336        if schema_name == replace_schema {
1337            // This schema should be replaced - check if the replacement schema is in extensions
1338            let replacement_exists = extension_refs.iter().any(|ext_ref| {
1339                let ext_schema_name = ext_ref.split('/').next_back().unwrap_or("");
1340                ext_schema_name == with_schema
1341            });
1342
1343            if replacement_exists {
1344                return true;
1345            }
1346        }
1347    }
1348
1349    // Fallback to exact name match for complete replacement
1350    extension_refs.iter().any(|ext_ref| {
1351        let ext_schema_name = ext_ref.split('/').next_back().unwrap_or("");
1352        schema_name == ext_schema_name
1353    })
1354}
1355
1356/// Recursively merge two JSON values with replacement rules
1357/// Objects are merged by combining properties
1358/// Arrays are merged by concatenating
1359/// Primitives in the extension override the main value
1360fn merge_json_objects_with_rules(
1361    main: Value,
1362    extension: Value,
1363    replacements: &std::collections::HashMap<String, (String, String)>,
1364) -> Value {
1365    match (main, extension) {
1366        // Both objects - merge properties
1367        (Value::Object(mut main_obj), Value::Object(ext_obj)) => {
1368            // Special handling for schema objects with oneOf/anyOf variants.
1369            // Detect which keyword the MAIN spec uses so we preserve it after merging.
1370            let main_union_keyword = if main_obj.contains_key("oneOf") {
1371                Some("oneOf")
1372            } else if main_obj.contains_key("anyOf") {
1373                Some("anyOf")
1374            } else {
1375                None
1376            };
1377            if let (Some(main_variants), Some(ext_variants)) = (
1378                extract_schema_variants(&Value::Object(main_obj.clone())),
1379                extract_schema_variants(&Value::Object(ext_obj.clone())),
1380            ) {
1381                let union_key = main_union_keyword.unwrap_or("oneOf");
1382                println!(
1383                    "🔍 Merging union schemas ({union_key}): {} main variants, {} extension variants",
1384                    main_variants.len(),
1385                    ext_variants.len()
1386                );
1387                // Merge the variant arrays, preserving the original union keyword
1388                // First, collect main variants, but filter out any that will be replaced by extension
1389                let mut merged_variants = Vec::new();
1390                let extension_refs: Vec<String> = ext_variants
1391                    .iter()
1392                    .filter_map(|v| v.get("$ref").and_then(|r| r.as_str()))
1393                    .map(|s| s.to_string())
1394                    .collect();
1395
1396                // Add main variants that aren't being replaced
1397                for main_variant in main_variants {
1398                    if let Some(main_ref) = main_variant.get("$ref").and_then(|r| r.as_str()) {
1399                        // Check if this main variant should be replaced by an extension variant
1400                        let schema_name = main_ref.split('/').next_back().unwrap_or("");
1401                        let should_replace =
1402                            should_replace_variant(schema_name, &extension_refs, replacements);
1403
1404                        if should_replace {
1405                            println!("🔄 REPLACING {} (explicit rule)", schema_name);
1406                        }
1407
1408                        if !should_replace {
1409                            merged_variants.push(main_variant);
1410                        }
1411                    } else {
1412                        // Keep non-ref variants
1413                        merged_variants.push(main_variant);
1414                    }
1415                }
1416
1417                // Add all extension variants
1418                for ext_variant in ext_variants {
1419                    merged_variants.push(ext_variant);
1420                }
1421
1422                // Remove old oneOf/anyOf keys and add merged variants under the original keyword
1423                main_obj.remove("oneOf");
1424                main_obj.remove("anyOf");
1425                main_obj.insert(union_key.to_string(), Value::Array(merged_variants));
1426
1427                // Merge other properties normally
1428                for (key, ext_value) in ext_obj {
1429                    if key != "oneOf" && key != "anyOf" {
1430                        match main_obj.get(&key) {
1431                            Some(main_value) => {
1432                                let merged_value = merge_json_objects_with_rules(
1433                                    main_value.clone(),
1434                                    ext_value,
1435                                    replacements,
1436                                );
1437                                main_obj.insert(key, merged_value);
1438                            }
1439                            None => {
1440                                main_obj.insert(key, ext_value);
1441                            }
1442                        }
1443                    }
1444                }
1445
1446                return Value::Object(main_obj);
1447            }
1448
1449            // Normal object merging
1450            for (key, ext_value) in ext_obj {
1451                match main_obj.get(&key) {
1452                    Some(main_value) => {
1453                        // Key exists in both - recursively merge
1454                        let merged_value = merge_json_objects_with_rules(
1455                            main_value.clone(),
1456                            ext_value,
1457                            replacements,
1458                        );
1459                        main_obj.insert(key, merged_value);
1460                    }
1461                    None => {
1462                        // Key only in extension - add it
1463                        main_obj.insert(key, ext_value);
1464                    }
1465                }
1466            }
1467            Value::Object(main_obj)
1468        }
1469
1470        // Both arrays - concatenate
1471        (Value::Array(mut main_arr), Value::Array(ext_arr)) => {
1472            main_arr.extend(ext_arr);
1473            Value::Array(main_arr)
1474        }
1475
1476        // Extension overrides main for all other cases
1477        (_, extension) => extension,
1478    }
1479}
1480
1481/// Extract schema variants from oneOf or anyOf properties
1482fn extract_schema_variants(obj: &Value) -> Option<Vec<Value>> {
1483    if let Value::Object(map) = obj {
1484        if let Some(Value::Array(variants)) = map.get("oneOf") {
1485            return Some(variants.clone());
1486        }
1487        if let Some(Value::Array(variants)) = map.get("anyOf") {
1488            return Some(variants.clone());
1489        }
1490    }
1491    None
1492}
1493
1494pub struct SchemaAnalyzer {
1495    schemas: BTreeMap<String, Schema>,
1496    resolved_cache: BTreeMap<String, AnalyzedSchema>,
1497    openapi_spec: Value,
1498    current_schema_name: Option<String>,
1499    component_parameters: BTreeMap<String, crate::openapi::Parameter>,
1500    /// Single chokepoint for `(openapi_type, format)` → Rust-type
1501    /// decisions (Q2.0). Defaulted when the analyzer is built without a
1502    /// config; threaded from `GeneratorConfig.types` via
1503    /// [`Self::with_type_mapper`].
1504    type_mapper: TypeMapper,
1505    /// Pointer targets currently being expanded, so a node that references
1506    /// itself through a pointer stops at a reference instead of recursing.
1507    resolving_pointers: HashSet<String>,
1508}
1509
1510impl SchemaAnalyzer {
1511    /// The type to emit when a schema gives analysis nothing to work with.
1512    /// Every `serde_json::Value` that analysis produces goes through here or
1513    /// [`Self::untyped_value_array`], so a fallback cannot escape the census.
1514    fn untyped_value(&self, _context: impl Into<String>, reason: UntypedReason) -> SchemaType {
1515        SchemaType::Untyped {
1516            shape: UntypedShape::Value,
1517            reason,
1518        }
1519    }
1520
1521    /// As [`Self::untyped_value`], for an array of unconstrained elements.
1522    fn untyped_value_array(
1523        &self,
1524        _context: impl Into<String>,
1525        reason: UntypedReason,
1526    ) -> SchemaType {
1527        SchemaType::Untyped {
1528            shape: UntypedShape::ValueArray,
1529            reason,
1530        }
1531    }
1532
1533    /// The schema currently being analyzed, for finding context.
1534    fn untyped_context(&self, detail: &str) -> String {
1535        match (&self.current_schema_name, detail) {
1536            (Some(schema), "") => schema.clone(),
1537            (Some(schema), detail) => format!("{schema}.{detail}"),
1538            (None, "") => "<anonymous>".to_string(),
1539            (None, detail) => detail.to_string(),
1540        }
1541    }
1542
1543    fn uses_aws_query_conventions(&self) -> bool {
1544        self.openapi_spec
1545            .pointer("/info/x-providerName")
1546            .and_then(Value::as_str)
1547            .is_some_and(|provider| provider.eq_ignore_ascii_case("amazonaws.com"))
1548    }
1549
1550    /// Construct an analyzer with a default [`TypeMapper`]. Pre-Q2.0
1551    /// callers (tests, simple bins) use this and get bit-identical
1552    /// behavior to the pre-refactor code.
1553    pub fn new(openapi_spec: Value) -> Result<Self> {
1554        Self::with_type_mapper(openapi_spec, TypeMapper::default())
1555    }
1556
1557    /// Construct an analyzer with a caller-supplied [`TypeMapper`]
1558    /// (built from `GeneratorConfig.types`). The CLI / library entry
1559    /// points use this so user TOML config drives type generation.
1560    pub fn with_type_mapper(mut openapi_spec: Value, type_mapper: TypeMapper) -> Result<Self> {
1561        disambiguate_component_schema_names(&mut openapi_spec);
1562        let spec: OpenApiSpec = parse_spec_document(&openapi_spec)?;
1563        let schemas = Self::extract_schemas(&spec)?;
1564
1565        let component_parameters = spec
1566            .components
1567            .as_ref()
1568            .and_then(|c| c.parameters.as_ref())
1569            .cloned()
1570            .unwrap_or_default();
1571        Ok(Self {
1572            schemas,
1573            resolved_cache: BTreeMap::new(),
1574            openapi_spec,
1575            current_schema_name: None,
1576            component_parameters,
1577            type_mapper,
1578            resolving_pointers: HashSet::new(),
1579        })
1580    }
1581
1582    /// Create a new analyzer with schema extensions merged in (default
1583    /// type mapper).
1584    pub fn new_with_extensions(
1585        openapi_spec: Value,
1586        extension_paths: &[std::path::PathBuf],
1587    ) -> Result<Self> {
1588        let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
1589        Self::new(merged_spec)
1590    }
1591
1592    /// Same as [`Self::new_with_extensions`] but with a caller-supplied
1593    /// type mapper.
1594    pub fn new_with_extensions_and_type_mapper(
1595        openapi_spec: Value,
1596        extension_paths: &[std::path::PathBuf],
1597        type_mapper: TypeMapper,
1598    ) -> Result<Self> {
1599        let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
1600        Self::with_type_mapper(merged_spec, type_mapper)
1601    }
1602
1603    /// Borrow the analyzer's type mapper. Useful for downstream
1604    /// inspection (e.g. the dep advisory in Q2.8 reads
1605    /// `type_mapper().used_features()` after generation).
1606    pub fn type_mapper(&self) -> &TypeMapper {
1607        &self.type_mapper
1608    }
1609
1610    /// Generate a context-aware name for inline types, arrays, and variants
1611    /// This provides better naming than generic names like UnionArray1, InlineVariant2, etc.
1612    fn generate_context_aware_name(
1613        &self,
1614        base_context: &str,
1615        type_hint: &str,
1616        index: usize,
1617        schema: Option<&Schema>,
1618    ) -> String {
1619        // First, try to infer a better name from the schema structure
1620        if let Some(schema) = schema {
1621            // For arrays, check if we can derive name from items
1622            if type_hint == "Array"
1623                && matches!(schema.schema_type(), Some(OpenApiSchemaType::Array))
1624            {
1625                if let Some(items_schema) = schema.details().item_schema() {
1626                    // Check for specific item types
1627                    if let Some(item_type) = items_schema.schema_type() {
1628                        match item_type {
1629                            OpenApiSchemaType::Object => {
1630                                return format!("{base_context}ItemArray");
1631                            }
1632                            OpenApiSchemaType::String => {
1633                                return format!("{base_context}StringArray");
1634                            }
1635                            _ => {}
1636                        }
1637                    }
1638                }
1639            }
1640        }
1641
1642        // Generate context-aware name based on type hint
1643        match type_hint {
1644            "Array" => {
1645                // For arrays, always use context name instead of generic numbering
1646                format!("{base_context}Array")
1647            }
1648            "Variant" | "InlineVariant" => {
1649                // For variants, include index only if > 0 to keep first variant clean
1650                if index == 0 {
1651                    format!("{base_context}{type_hint}")
1652                } else {
1653                    format!("{}{}{}", base_context, type_hint, index + 1)
1654                }
1655            }
1656            _ => {
1657                // Default case
1658                format!("{base_context}{type_hint}{index}")
1659            }
1660        }
1661    }
1662
1663    /// Convert a string to PascalCase, handling underscores and hyphens
1664    fn to_pascal_case(&self, s: &str) -> String {
1665        s.split(['_', '-'])
1666            .filter(|part| !part.is_empty())
1667            .map(|part| {
1668                let mut chars = part.chars();
1669                match chars.next() {
1670                    None => String::new(),
1671                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1672                }
1673            })
1674            .collect()
1675    }
1676
1677    fn extract_schemas(spec: &OpenApiSpec) -> Result<BTreeMap<String, Schema>> {
1678        // OAS 3.1+ requires only one of `paths`, `webhooks`, or `components`.
1679        // A document may legitimately have no `components.schemas` (e.g. a
1680        // webhooks-only or paths-only spec). Return an empty map in that case
1681        // and let downstream codegen handle "no types to emit" gracefully.
1682        let schemas = spec.components.as_ref().and_then(|c| c.schemas.as_ref());
1683        Ok(schemas
1684            .map(|m| {
1685                m.iter()
1686                    .map(|(k, v)| (k.clone(), v.clone()))
1687                    .collect::<BTreeMap<_, _>>()
1688            })
1689            .unwrap_or_default())
1690    }
1691
1692    pub fn analyze(&mut self) -> Result<SchemaAnalysis> {
1693        let validation_context = ValidationContext {
1694            openapi_version: self
1695                .openapi_spec
1696                .get("openapi")
1697                .and_then(Value::as_str)
1698                .unwrap_or_default()
1699                .to_string(),
1700            json_schema_dialect: self
1701                .openapi_spec
1702                .get("jsonSchemaDialect")
1703                .and_then(Value::as_str)
1704                .map(str::to_string),
1705            component_schemas: self
1706                .openapi_spec
1707                .pointer("/components/schemas")
1708                .and_then(Value::as_object)
1709                .map(|schemas| {
1710                    schemas
1711                        .iter()
1712                        .map(|(name, schema)| (name.clone(), schema.clone()))
1713                        .collect()
1714                })
1715                .unwrap_or_default(),
1716        };
1717        let mut analysis = SchemaAnalysis {
1718            schemas: BTreeMap::new(),
1719            dependencies: DependencyGraph::new(),
1720            patterns: DetectedPatterns {
1721                tagged_enum_schemas: HashSet::new(),
1722                untagged_enum_schemas: HashSet::new(),
1723                type_mappings: BTreeMap::new(),
1724            },
1725            operations: BTreeMap::new(),
1726            operation_responses: BTreeMap::new(),
1727            operation_id_aliases: BTreeMap::new(),
1728            used_type_features: crate::type_mapping::UsedFeatures::default(),
1729            enum_extensions: BTreeMap::new(),
1730            validation_context,
1731        };
1732
1733        // First pass: detect patterns
1734        self.detect_patterns(&mut analysis.patterns)?;
1735
1736        // Second pass: analyze each schema
1737        let schema_names: Vec<String> = self.schemas.keys().cloned().collect();
1738        for schema_name in schema_names {
1739            let analyzed = self.analyze_schema(&schema_name)?;
1740
1741            // Build dependency graph
1742            for dep in &analyzed.dependencies {
1743                analysis
1744                    .dependencies
1745                    .add_dependency(schema_name.clone(), dep.clone());
1746            }
1747
1748            analysis.schemas.insert(schema_name, analyzed);
1749        }
1750
1751        // Third pass: include any inline schemas that were generated during analysis
1752        // BTreeMap maintains sorted order, so iteration is deterministic
1753        for (inline_name, inline_schema) in &self.resolved_cache {
1754            if !analysis.schemas.contains_key(inline_name) {
1755                // Add the inline schema first
1756                analysis
1757                    .schemas
1758                    .insert(inline_name.clone(), inline_schema.clone());
1759
1760                // Build dependency graph for inline schema's own dependencies
1761                for dep in &inline_schema.dependencies {
1762                    analysis
1763                        .dependencies
1764                        .add_dependency(inline_name.clone(), dep.clone());
1765                }
1766
1767                // Check if any existing schemas depend on this inline schema
1768                // We need to check ALL schemas, not just the ones already in analysis.schemas,
1769                // because parent schemas might have been analyzed but their dependencies
1770                // on inline schemas might not have been added to the dependency graph yet
1771                let mut schemas_to_update = Vec::new();
1772                for (schema_name, schema) in &analysis.schemas {
1773                    // Skip self-reference
1774                    if schema_name == inline_name {
1775                        continue;
1776                    }
1777
1778                    if schema.dependencies.contains(inline_name) {
1779                        // The parent schema depends on this inline schema
1780                        schemas_to_update.push(schema_name.clone());
1781                    }
1782                }
1783
1784                // Add the dependencies to the graph
1785                for schema_name in schemas_to_update {
1786                    analysis
1787                        .dependencies
1788                        .add_dependency(schema_name, inline_name.clone());
1789                }
1790            }
1791        }
1792
1793        // Fourth pass: analyze OpenAPI operations
1794        self.analyze_operations(&mut analysis)?;
1795
1796        // Fifth pass: include any inline schemas generated during operation analysis
1797        // (e.g., inline response types)
1798        for (inline_name, inline_schema) in &self.resolved_cache {
1799            if !analysis.schemas.contains_key(inline_name) {
1800                analysis
1801                    .schemas
1802                    .insert(inline_name.clone(), inline_schema.clone());
1803
1804                // Build dependency graph for inline schema's dependencies
1805                for dep in &inline_schema.dependencies {
1806                    analysis
1807                        .dependencies
1808                        .add_dependency(inline_name.clone(), dep.clone());
1809                }
1810            }
1811        }
1812
1813        disambiguate_analyzed_schema_names(&mut analysis, &self.schemas);
1814
1815        // Snapshot the type-mapper's used-features set so the
1816        // generator can decide which helper modules to emit
1817        // (e.g. base64_serde for `format: byte`).
1818        analysis.used_type_features = self.type_mapper.used_features();
1819
1820        // Q2.6: capture x-enum-varnames / x-enum-descriptions from
1821        // each enum schema's original JSON. Side-channel keyed by
1822        // analyzed-schema name so we don't have to extend every
1823        // SchemaType::StringEnum constructor.
1824        for (name, analyzed) in &analysis.schemas {
1825            let enum_value_count = match &analyzed.schema_type {
1826                SchemaType::StringEnum { values } => values.len(),
1827                SchemaType::ExtensibleEnum { known_values } => known_values.len(),
1828                _ => continue,
1829            };
1830            if let Some(ext) = extract_enum_extensions(&analyzed.original, enum_value_count, name) {
1831                analysis.enum_extensions.insert(name.clone(), ext);
1832            }
1833        }
1834
1835        for schema in analysis.schemas.values_mut() {
1836            normalize_untyped(&mut schema.schema_type, 0);
1837        }
1838
1839        Ok(analysis)
1840    }
1841
1842    fn detect_patterns(&self, patterns: &mut DetectedPatterns) -> Result<()> {
1843        for (schema_name, schema) in &self.schemas {
1844            // Detect discriminated unions
1845            if self.is_discriminated_union(schema) {
1846                patterns.tagged_enum_schemas.insert(schema_name.clone());
1847
1848                // Extract type mappings for this union
1849                if let Some(mappings) = self.extract_type_mappings(schema)? {
1850                    patterns.type_mappings.insert(schema_name.clone(), mappings);
1851                }
1852            }
1853            // Detect simple unions
1854            else if self.is_simple_union(schema) {
1855                patterns.untagged_enum_schemas.insert(schema_name.clone());
1856            }
1857        }
1858
1859        Ok(())
1860    }
1861
1862    fn is_discriminated_union(&self, schema: &Schema) -> bool {
1863        // Check for explicit discriminator
1864        if schema.is_discriminated_union() {
1865            return true;
1866        }
1867
1868        // Auto-detect from union patterns with any common const field
1869        if let Some(variants) = schema.union_variants() {
1870            return variants.len() > 2 && self.detect_discriminator_field(variants).is_some();
1871        }
1872
1873        false
1874    }
1875
1876    fn all_variants_have_unique_const_values(&self, variants: &[Schema], field_name: &str) -> bool {
1877        let mut values = HashSet::new();
1878
1879        variants.iter().all(|variant| {
1880            let schema = if let Some(ref_str) = variant.reference() {
1881                let Some(schema_name) = self.extract_schema_name(ref_str) else {
1882                    return false;
1883                };
1884                let Some(schema) = self.schemas.get(schema_name) else {
1885                    return false;
1886                };
1887                schema
1888            } else {
1889                variant
1890            };
1891
1892            self.extract_discriminator_value_for_field(schema, field_name)
1893                .is_some_and(|value| values.insert(value))
1894        })
1895    }
1896
1897    /// True when this branch of an anyOf/oneOf is (or resolves to) an
1898    /// object — the only kind of schema serde can deserialize via an
1899    /// internally-tagged enum. False for string/number/bool/array branches
1900    /// or refs to those, including string-enums.
1901    ///
1902    /// Used to detect the "hybrid string-or-object" union pattern (see bug
1903    /// openapi-generator-dpd) so we can downgrade those unions to
1904    /// `#[serde(untagged)]`.
1905    fn branch_resolves_to_object(&self, schema: &Schema) -> bool {
1906        // Follow $ref one hop, then ask the same question of the target.
1907        if let Some(ref_str) = schema.reference() {
1908            return match self
1909                .extract_schema_name(ref_str)
1910                .and_then(|n| self.schemas.get(n))
1911            {
1912                Some(target) => self.branch_resolves_to_object(target),
1913                None => false,
1914            };
1915        }
1916        // allOf compositions are object-shaped; same for anyOf/oneOf
1917        // wrappers (those will reduce to objects or to further unions).
1918        if matches!(
1919            schema,
1920            Schema::AllOf { .. } | Schema::AnyOf { .. } | Schema::OneOf { .. }
1921        ) {
1922            return true;
1923        }
1924        if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object)) {
1925            return true;
1926        }
1927        if schema.inferred_type() == Some(OpenApiSchemaType::Object) {
1928            return true;
1929        }
1930        // Anything else (string, integer, number, boolean, array, null,
1931        // string-enum, etc.) cannot carry a JSON tag field.
1932        false
1933    }
1934
1935    /// Scan all variants to find any common property that has a const/single-enum value
1936    /// across all variants. Returns the field name if found.
1937    /// Prioritizes "type" if it matches (most common convention).
1938    fn detect_discriminator_field(&self, variants: &[Schema]) -> Option<String> {
1939        if variants.is_empty() {
1940            return None;
1941        }
1942
1943        // Collect candidate field names from the first variant
1944        let first_variant = &variants[0];
1945        let first_schema = if let Some(ref_str) = first_variant.reference() {
1946            let schema_name = self.extract_schema_name(ref_str)?;
1947            self.schemas.get(schema_name)?
1948        } else {
1949            first_variant
1950        };
1951
1952        let properties = first_schema.details().properties.as_ref()?;
1953        let mut candidates: Vec<String> = Vec::new();
1954
1955        for (field_name, field_schema) in properties {
1956            let details = field_schema.details();
1957            let is_const = details.const_value.is_some()
1958                || details.enum_values.as_ref().is_some_and(|v| v.len() == 1)
1959                || details.extra.contains_key("const");
1960            if is_const {
1961                candidates.push(field_name.clone());
1962            }
1963        }
1964
1965        if candidates.is_empty() {
1966            return None;
1967        }
1968
1969        // Prioritize "type" if it's among candidates
1970        candidates.sort_by(|a, b| {
1971            if a == "type" {
1972                std::cmp::Ordering::Less
1973            } else if b == "type" {
1974                std::cmp::Ordering::Greater
1975            } else {
1976                a.cmp(b)
1977            }
1978        });
1979
1980        // A discriminator is only useful when every branch has a distinct
1981        // value. Repeated values would generate duplicate serde rename tags,
1982        // making later branches impossible to deserialize. In that case the
1983        // caller falls back to an untagged union so nested const fields can
1984        // participate in matching.
1985        for candidate in &candidates {
1986            if self.all_variants_have_unique_const_values(variants, candidate) {
1987                return Some(candidate.clone());
1988            }
1989        }
1990
1991        None
1992    }
1993
1994    fn is_simple_union(&self, schema: &Schema) -> bool {
1995        if let Some(variants) = schema.union_variants() {
1996            // Simple union: multiple types but not nullable pattern
1997            if variants.len() > 1 && !schema.is_nullable_pattern() {
1998                let has_refs = variants.iter().any(|v| v.is_reference());
1999                return has_refs;
2000            }
2001        }
2002        false
2003    }
2004
2005    fn extract_type_mappings(&self, schema: &Schema) -> Result<Option<BTreeMap<String, String>>> {
2006        let variants = schema.union_variants().ok_or_else(|| {
2007            GeneratorError::InvalidSchema("No variants found for discriminated union".to_string())
2008        })?;
2009
2010        // Get the discriminator field name from the schema
2011        let discriminator_field = if let Some(discriminator) = schema.discriminator() {
2012            discriminator.property_name.clone()
2013        } else if let Some(detected) = self.detect_discriminator_field(variants) {
2014            detected
2015        } else {
2016            "type".to_string() // fallback to "type" for auto-detected discriminated unions
2017        };
2018
2019        let mut mappings = BTreeMap::new();
2020
2021        for variant in variants {
2022            if let Some(ref_str) = variant.reference() {
2023                if let Some(type_name) = self.extract_schema_name(ref_str) {
2024                    if let Some(variant_schema) = self.schemas.get(type_name) {
2025                        if let Some(discriminator_value) = self
2026                            .extract_discriminator_value_for_field(
2027                                variant_schema,
2028                                &discriminator_field,
2029                            )
2030                        {
2031                            mappings.insert(type_name.to_string(), discriminator_value);
2032                        }
2033                    }
2034                }
2035            }
2036        }
2037
2038        if mappings.is_empty() {
2039            Ok(None)
2040        } else {
2041            Ok(Some(mappings))
2042        }
2043    }
2044
2045    #[allow(dead_code)]
2046    fn extract_discriminator_value(&self, schema: &Schema) -> Option<String> {
2047        self.extract_discriminator_value_for_field(schema, "type")
2048    }
2049
2050    fn extract_discriminator_value_for_field(
2051        &self,
2052        schema: &Schema,
2053        field_name: &str,
2054    ) -> Option<String> {
2055        if let Some(properties) = &schema.details().properties {
2056            if let Some(type_field) = properties.get(field_name) {
2057                // Check for const value first (highest priority)
2058                if let Some(const_value) = &type_field.details().const_value {
2059                    if let Some(value) = const_value.as_str() {
2060                        return Some(value.to_string());
2061                    }
2062                }
2063                // Check for enum with single value
2064                if let Some(enum_values) = &type_field.details().enum_values {
2065                    if enum_values.len() == 1 {
2066                        return enum_values[0].as_str().map(|s| s.to_string());
2067                    }
2068                }
2069                // Check for const value in extra fields
2070                if let Some(const_value) = type_field.details().extra.get("const") {
2071                    return const_value.as_str().map(|s| s.to_string());
2072                }
2073                // Check for x-stainless-const with default value
2074                if let Some(stainless_const) = type_field.details().extra.get("x-stainless-const") {
2075                    if stainless_const.as_bool() == Some(true) {
2076                        if let Some(default_value) = &type_field.details().default {
2077                            if let Some(value) = default_value.as_str() {
2078                                return Some(value.to_string());
2079                            }
2080                        }
2081                    }
2082                }
2083            }
2084        }
2085        None
2086    }
2087
2088    fn get_any_reference<'a>(&self, schema: &'a Schema) -> Option<&'a str> {
2089        schema.reference().or_else(|| schema.recursive_reference())
2090    }
2091
2092    fn extract_schema_name<'a>(&self, ref_str: &'a str) -> Option<&'a str> {
2093        if ref_str == "#" {
2094            return None; // Special case for self-reference
2095        }
2096
2097        let parts: Vec<&str> = ref_str.split('/').collect();
2098
2099        // Standard 3.x pattern: #/components/schemas/{SchemaName}[/deeper/path]
2100        if parts.len() >= 4 && parts[0] == "#" && parts[2] == "schemas" {
2101            return Some(parts[3]);
2102        }
2103
2104        // Swagger 2.0 carry-over: some 3.x specs (Google) still use
2105        // `#/definitions/{SchemaName}`. Treat it as an alias.
2106        if parts.len() >= 3 && parts[0] == "#" && parts[1] == "definitions" {
2107            return Some(parts[2]);
2108        }
2109
2110        // Last-segment fallback for other ref shapes — but only if the
2111        // segment plausibly names a top-level schema (PascalCase, no digits-
2112        // only, not a JSON-schema keyword like `schema`/`properties`/`items`).
2113        // pagerduty has `#/components/parameters/foo/schema`, where the last
2114        // segment "schema" is a sub-path indicator, not a schema name.
2115        let last = parts.last()?;
2116        if last.is_empty()
2117            || last.chars().all(|c| c.is_ascii_digit())
2118            || matches!(
2119                *last,
2120                "schema" | "properties" | "items" | "additionalProperties"
2121            )
2122        {
2123            return None;
2124        }
2125        let first = last.chars().next().unwrap_or(' ');
2126        if !first.is_ascii_alphabetic() || !first.is_ascii_uppercase() {
2127            return None;
2128        }
2129        Some(last)
2130    }
2131
2132    fn analyze_schema(&mut self, schema_name: &str) -> Result<AnalyzedSchema> {
2133        // Check cache first
2134        if let Some(cached) = self.resolved_cache.get(schema_name) {
2135            return Ok(cached.clone());
2136        }
2137
2138        // Set current schema name for context
2139        self.current_schema_name = Some(schema_name.to_string());
2140
2141        let schema = self
2142            .schemas
2143            .get(schema_name)
2144            .ok_or_else(|| GeneratorError::UnresolvedReference(schema_name.to_string()))?
2145            .clone();
2146
2147        // Prevent infinite recursion with placeholder
2148        self.resolved_cache.insert(
2149            schema_name.to_string(),
2150            AnalyzedSchema {
2151                name: schema_name.to_string(),
2152                original: serde_json::to_value(&schema).unwrap_or(Value::Null),
2153                schema_type: SchemaType::Reference {
2154                    target: "placeholder".to_string(),
2155                },
2156                dependencies: HashSet::new(),
2157                nullable: false,
2158                description: None,
2159                default: None,
2160            },
2161        );
2162
2163        let analyzed = self.analyze_schema_value(&schema, schema_name)?;
2164
2165        // Update cache with real result
2166        self.resolved_cache
2167            .insert(schema_name.to_string(), analyzed.clone());
2168
2169        Ok(analyzed)
2170    }
2171
2172    fn analyze_schema_value(
2173        &mut self,
2174        schema: &Schema,
2175        schema_name: &str,
2176    ) -> Result<AnalyzedSchema> {
2177        let details = schema.details();
2178        let description = details.description.clone();
2179        // Combine 3.0-style `nullable: true` with 3.1's `type: ["X", "null"]`.
2180        let nullable = details.is_nullable() || schema.type_array_contains_null();
2181        let mut dependencies = HashSet::new();
2182
2183        let schema_type = match schema {
2184            Schema::Reference { reference, .. } => {
2185                // A ref that names no component schema may still address a node
2186                // in this document — a parameter's schema, a response body, one
2187                // member of a composition. Resolve the pointer before giving up
2188                // and typing the field as opaque JSON.
2189                match self.extract_schema_name(reference) {
2190                    Some(name) => {
2191                        let target = name.to_string();
2192                        dependencies.insert(target.clone());
2193                        SchemaType::Reference { target }
2194                    }
2195                    None => {
2196                        let reference = reference.clone();
2197                        if let Some(resolved) =
2198                            self.resolve_pointer_schema(&reference, &mut dependencies)?
2199                        {
2200                            resolved
2201                        } else {
2202                            eprintln!(
2203                                "⚠️  unresolvable $ref `{}` — typing as serde_json::Value",
2204                                reference
2205                            );
2206                            self.untyped_value(
2207                                format!("$ref {reference}"),
2208                                UntypedReason::UnresolvedReference,
2209                            )
2210                        }
2211                    }
2212                }
2213            }
2214            Schema::RecursiveRef { recursive_ref, .. }
2215            | Schema::DynamicRef {
2216                dynamic_ref: recursive_ref,
2217                ..
2218            } => {
2219                // Handle recursive / dynamic references. J1: full $dynamicRef
2220                // resolution against $dynamicAnchor scopes is a follow-up; for
2221                // now we treat them like recursive refs (self-reference when
2222                // it's a fragment to the same schema, otherwise resolve via
2223                // schema name).
2224                if recursive_ref == "#" {
2225                    dependencies.insert(schema_name.to_string());
2226                    SchemaType::Reference {
2227                        target: schema_name.to_string(),
2228                    }
2229                } else {
2230                    let target = self
2231                        .extract_schema_name(recursive_ref)
2232                        .unwrap_or(schema_name)
2233                        .to_string();
2234                    dependencies.insert(target.clone());
2235                    SchemaType::Reference { target }
2236                }
2237            }
2238            Schema::Typed { .. } | Schema::TypedMulti { .. } => {
2239                if let Some(non_null_types) = schema.non_null_schema_types() {
2240                    let mut variants = Vec::with_capacity(non_null_types.len());
2241                    for t in non_null_types {
2242                        variants.push(self.build_typed_multi_union_variant(
2243                            t,
2244                            schema,
2245                            schema_name,
2246                            &mut dependencies,
2247                        )?);
2248                    }
2249                    SchemaType::Union { variants }
2250                } else {
2251                    self.analyze_single_typed_schema(
2252                        schema,
2253                        schema_name,
2254                        details,
2255                        &mut dependencies,
2256                    )?
2257                }
2258            }
2259            Schema::AnyOf {
2260                any_of,
2261                discriminator,
2262                ..
2263            } => {
2264                if Self::union_only_constrains_requiredness(any_of) {
2265                    return Ok(AnalyzedSchema {
2266                        name: schema_name.to_string(),
2267                        original: serde_json::to_value(schema).unwrap_or(Value::Null),
2268                        schema_type: self.analyze_empty_union(schema, &mut dependencies)?,
2269                        dependencies,
2270                        nullable,
2271                        description,
2272                        default: details.default.clone(),
2273                    });
2274                }
2275                if let Some(schema_type) = self.analyze_object_with_variants(
2276                    schema,
2277                    any_of,
2278                    schema_name,
2279                    &mut dependencies,
2280                )? {
2281                    return Ok(AnalyzedSchema {
2282                        name: schema_name.to_string(),
2283                        original: serde_json::to_value(schema).unwrap_or(Value::Null),
2284                        schema_type,
2285                        dependencies,
2286                        nullable,
2287                        description,
2288                        default: details.default.clone(),
2289                    });
2290                }
2291                // Handle anyOf patterns (nullable vs flexible union vs discriminated)
2292                self.analyze_anyof_union(
2293                    any_of,
2294                    discriminator.as_ref(),
2295                    &mut dependencies,
2296                    schema_name,
2297                )?
2298            }
2299            Schema::OneOf {
2300                one_of,
2301                discriminator,
2302                ..
2303            } => {
2304                if one_of.is_empty() {
2305                    self.analyze_empty_union(schema, &mut dependencies)?
2306                } else if let Some(schema_type) = self.analyze_object_with_variants(
2307                    schema,
2308                    one_of,
2309                    schema_name,
2310                    &mut dependencies,
2311                )? {
2312                    schema_type
2313                } else {
2314                    // Handle oneOf discriminated unions
2315                    self.analyze_oneof_union(
2316                        one_of,
2317                        discriminator.as_ref(),
2318                        schema_name,
2319                        &mut dependencies,
2320                    )?
2321                }
2322            }
2323            Schema::AllOf { all_of, .. } => {
2324                // Handle allOf composition (schema inheritance)
2325                self.analyze_allof_composition(all_of, &mut dependencies)?
2326            }
2327            Schema::Untyped { .. } => {
2328                // Try to infer type from structure
2329                if let Some(inferred) = schema.inferred_type() {
2330                    match inferred {
2331                        OpenApiSchemaType::Object => {
2332                            if self.should_use_dynamic_json(schema) {
2333                                self.untyped_value(
2334                                    self.untyped_context(""),
2335                                    UntypedReason::OpaqueObject,
2336                                )
2337                            } else {
2338                                self.analyze_object_schema(schema, &mut dependencies)?
2339                            }
2340                        }
2341                        OpenApiSchemaType::String if details.is_string_enum() => {
2342                            SchemaType::StringEnum {
2343                                values: details.string_enum_values().unwrap_or_default(),
2344                            }
2345                        }
2346                        // `type: null` admits exactly one value; Rust spells
2347                        // that `()`, which serde reads from and writes as null.
2348                        OpenApiSchemaType::Null => SchemaType::Primitive {
2349                            rust_type: self.type_mapper.null_unit().rust_type,
2350                            serde_with: None,
2351                        },
2352                        _ => self.untyped_value(
2353                            self.untyped_context(""),
2354                            UntypedReason::UnsupportedTypeKeyword,
2355                        ),
2356                    }
2357                } else {
2358                    self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema)
2359                }
2360            }
2361        };
2362
2363        Ok(AnalyzedSchema {
2364            name: schema_name.to_string(),
2365            original: serde_json::to_value(schema).unwrap_or(Value::Null), // Convert back to Value for now
2366            schema_type,
2367            dependencies,
2368            nullable,
2369            description,
2370            default: details.default.clone(),
2371        })
2372    }
2373
2374    /// Resolve a `Schema::Typed`/`Schema::TypedMulti` schema that carries a
2375    /// single effective type (the 3.1 nullable shorthand already collapses
2376    /// to this via `schema_type()`). Proper multi-type unions are handled in
2377    /// [Self::analyze_schema_value] via
2378    /// [Self::build_typed_multi_union_variant].
2379    fn analyze_single_typed_schema(
2380        &mut self,
2381        schema: &Schema,
2382        schema_name: &str,
2383        details: &crate::openapi::SchemaDetails,
2384        dependencies: &mut HashSet<String>,
2385    ) -> Result<SchemaType> {
2386        let primary = schema
2387            .schema_type()
2388            .cloned()
2389            .unwrap_or(OpenApiSchemaType::Object);
2390        let format = details.format.as_deref();
2391        Ok(match primary {
2392            OpenApiSchemaType::String => {
2393                if let Some(values) = details.string_enum_values() {
2394                    SchemaType::StringEnum { values }
2395                } else {
2396                    SchemaType::Primitive {
2397                        rust_type: self.type_mapper.string_format(format).rust_type,
2398                        serde_with: None,
2399                    }
2400                }
2401            }
2402            OpenApiSchemaType::Integer => SchemaType::Primitive {
2403                rust_type: self.type_mapper.integer_format(format).rust_type,
2404                serde_with: None,
2405            },
2406            OpenApiSchemaType::Number => SchemaType::Primitive {
2407                rust_type: self.type_mapper.number_format(format).rust_type,
2408                serde_with: None,
2409            },
2410            OpenApiSchemaType::Boolean => SchemaType::Primitive {
2411                rust_type: self.type_mapper.boolean().rust_type,
2412                serde_with: None,
2413            },
2414            OpenApiSchemaType::Array => {
2415                self.analyze_array_schema(schema, schema_name, dependencies)?
2416            }
2417            OpenApiSchemaType::Object => {
2418                if self.should_use_dynamic_json(schema) {
2419                    self.untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject)
2420                } else {
2421                    self.analyze_object_schema(schema, dependencies)?
2422                }
2423            }
2424            // `type: null` admits exactly one value. Rust spells that `()`,
2425            // which serde reads from and writes as `null`.
2426            OpenApiSchemaType::Null => SchemaType::Primitive {
2427                rust_type: self.type_mapper.null_unit().rust_type,
2428                serde_with: None,
2429            },
2430        })
2431    }
2432
2433    fn analyze_object_schema(
2434        &mut self,
2435        schema: &Schema,
2436        dependencies: &mut HashSet<String>,
2437    ) -> Result<SchemaType> {
2438        let details = schema.details();
2439        let properties = &details.properties;
2440        let required = details
2441            .required
2442            .as_ref()
2443            .map(|req| req.iter().cloned().collect::<HashSet<String>>())
2444            .unwrap_or_default();
2445
2446        let mut property_info = BTreeMap::new();
2447        // Names hoisted property types after the schema being analyzed, which
2448        // is what `{Parent}{Property}` reads as in generated code.
2449        let owner_name = self
2450            .current_schema_name
2451            .clone()
2452            .unwrap_or_else(|| "Inline".to_string());
2453
2454        if let Some(props) = properties {
2455            for (prop_name, prop_schema) in props {
2456                // Check if this property is a union that needs a named type
2457                let prop_type = if let Schema::AnyOf { any_of, .. } = prop_schema {
2458                    // The union may sit alongside the property's own
2459                    // properties, or constrain only which of them are
2460                    // required — OpenAI's `tool_resources.file_search` is
2461                    // `{properties: {...}, anyOf: [{required: [a]}, {required: [b]}]}`.
2462                    if Self::union_only_constrains_requiredness(any_of) {
2463                        self.analyze_empty_union(prop_schema, dependencies)?
2464                    } else if let Some(with_variants) = self.analyze_object_with_variants(
2465                        prop_schema,
2466                        any_of,
2467                        &format!("{owner_name}{}", self.to_pascal_case(prop_name)),
2468                        dependencies,
2469                    )? {
2470                        with_variants
2471                    } else if self.should_use_dynamic_json(prop_schema) {
2472                        // This is a dynamic JSON pattern, use serde_json::Value directly
2473                        self.untyped_value(
2474                            self.untyped_context(prop_name),
2475                            UntypedReason::OpaqueObject,
2476                        )
2477                    } else if prop_schema.is_nullable_pattern()
2478                        && let Some(non_null) = prop_schema.non_null_variant()
2479                    {
2480                        // 3.1 idiom: `anyOf: [<schema>, {type: null}]`. The
2481                        // wrapper has no semantic value beyond nullability;
2482                        // unwrap to the inner type. Without this, the synthesized
2483                        // wrapper type collides with the inner $ref's name when
2484                        // the property name produces a colliding parent context
2485                        // (e.g. `Step.status` → `StepStatus`, which is also the
2486                        // referenced component).
2487                        self.analyze_property_schema_with_context(
2488                            non_null,
2489                            Some(prop_name),
2490                            dependencies,
2491                        )?
2492                    } else {
2493                        // This is an anyOf union in a property - create a named union type
2494                        // Use the current schema name as context to make the union name unique
2495                        let context_name = self
2496                            .current_schema_name
2497                            .clone()
2498                            .unwrap_or_else(|| "Unknown".to_string());
2499
2500                        // Generate a name based on both the schema and property name
2501                        let prop_pascal = self.to_pascal_case(prop_name);
2502                        let mut union_type_name = format!("{context_name}{prop_pascal}");
2503
2504                        // Avoid colliding with an existing component schema or
2505                        // an inline name that's already in resolved_cache.
2506                        if self.schemas.contains_key(&union_type_name)
2507                            || self.resolved_cache.contains_key(&union_type_name)
2508                        {
2509                            let mut suffix = 2;
2510                            loop {
2511                                let candidate = format!("{union_type_name}Union{suffix}");
2512                                if !self.schemas.contains_key(&candidate)
2513                                    && !self.resolved_cache.contains_key(&candidate)
2514                                {
2515                                    union_type_name = candidate;
2516                                    break;
2517                                }
2518                                suffix += 1;
2519                                if suffix > 1000 {
2520                                    break;
2521                                }
2522                            }
2523                        }
2524
2525                        // Analyze the union
2526                        let union_schema_type = self.analyze_anyof_union(
2527                            any_of,
2528                            prop_schema.discriminator(),
2529                            dependencies,
2530                            &union_type_name,
2531                        )?;
2532
2533                        // Store the union as a named schema
2534                        self.resolved_cache.insert(
2535                            union_type_name.clone(),
2536                            AnalyzedSchema {
2537                                name: union_type_name.clone(),
2538                                original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
2539                                dependencies: schema_type_dependencies(&union_schema_type),
2540                                schema_type: union_schema_type,
2541                                nullable: false,
2542                                description: prop_schema.details().description.clone(),
2543                                default: None,
2544                            },
2545                        );
2546
2547                        // Return a reference to the named union type
2548                        dependencies.insert(union_type_name.clone());
2549                        SchemaType::Reference {
2550                            target: union_type_name,
2551                        }
2552                    }
2553                } else if let Schema::OneOf {
2554                    one_of,
2555                    discriminator,
2556                    ..
2557                } = prop_schema
2558                {
2559                    // 3.1 idiom: `oneOf: [<schema>, {type: null}]`. Same
2560                    // unwrap as anyOf above — without this, the synthesized
2561                    // wrapper type collides with the inner $ref's name
2562                    // (discord's `QuarantineUserAction.metadata` →
2563                    // `QuarantineUserActionMetadata` clashing with the
2564                    // referenced `QuarantineUserActionMetadata` schema).
2565                    if prop_schema.is_nullable_pattern()
2566                        && let Some(non_null) = prop_schema.non_null_variant()
2567                    {
2568                        let unwrapped = self.analyze_property_schema_with_context(
2569                            non_null,
2570                            Some(prop_name),
2571                            dependencies,
2572                        )?;
2573                        let owner_name = self
2574                            .current_schema_name
2575                            .clone()
2576                            .unwrap_or_else(|| "Inline".to_string());
2577                        let unwrapped = self.hoist_inline_property_type(
2578                            &owner_name,
2579                            prop_name,
2580                            unwrapped,
2581                            dependencies,
2582                        );
2583                        let prop_details = prop_schema.details();
2584                        let prop_nullable = true;
2585                        let prop_description = prop_details.description.clone();
2586                        let prop_default = prop_details.default.clone();
2587                        property_info.insert(
2588                            prop_name.clone(),
2589                            PropertyInfo {
2590                                schema_type: unwrapped,
2591                                nullable: prop_nullable,
2592                                description: prop_description,
2593                                default: prop_default,
2594                                serde_attrs: Vec::new(),
2595                                constraints: PropertyConstraints::from_schema_details(prop_details),
2596                            },
2597                        );
2598                        continue;
2599                    }
2600
2601                    // Handle oneOf discriminated unions in properties
2602                    let context_name = self
2603                        .current_schema_name
2604                        .clone()
2605                        .unwrap_or_else(|| "Unknown".to_string());
2606                    let prop_pascal = self.to_pascal_case(prop_name);
2607                    let mut union_type_name = format!("{context_name}{prop_pascal}");
2608                    // Same collision-suffix dance as the anyOf branch above.
2609                    if self.schemas.contains_key(&union_type_name)
2610                        || self.resolved_cache.contains_key(&union_type_name)
2611                    {
2612                        let mut suffix = 2;
2613                        loop {
2614                            let candidate = format!("{union_type_name}Union{suffix}");
2615                            if !self.schemas.contains_key(&candidate)
2616                                && !self.resolved_cache.contains_key(&candidate)
2617                            {
2618                                union_type_name = candidate;
2619                                break;
2620                            }
2621                            suffix += 1;
2622                            if suffix > 1000 {
2623                                break;
2624                            }
2625                        }
2626                    }
2627
2628                    // Analyze the discriminated union
2629                    let union_schema_type = self.analyze_oneof_union(
2630                        one_of,
2631                        discriminator.as_ref(),
2632                        &union_type_name,
2633                        dependencies,
2634                    )?;
2635
2636                    // Store the union as a named schema
2637                    self.resolved_cache.insert(
2638                        union_type_name.clone(),
2639                        AnalyzedSchema {
2640                            name: union_type_name.clone(),
2641                            original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
2642                            schema_type: union_schema_type,
2643                            dependencies: HashSet::new(),
2644                            nullable: false,
2645                            description: prop_schema.details().description.clone(),
2646                            default: None,
2647                        },
2648                    );
2649
2650                    // Return a reference to the named union type
2651                    dependencies.insert(union_type_name.clone());
2652                    SchemaType::Reference {
2653                        target: union_type_name,
2654                    }
2655                } else {
2656                    // Regular property schema analysis - pass property name for context
2657                    self.analyze_property_schema_with_context(
2658                        prop_schema,
2659                        Some(prop_name),
2660                        dependencies,
2661                    )?
2662                };
2663
2664                let prop_type = self.hoist_inline_property_type(
2665                    &owner_name,
2666                    prop_name,
2667                    prop_type,
2668                    dependencies,
2669                );
2670
2671                let prop_details = prop_schema.details();
2672                // Every nullability form, via one helper — see is_nullable_any.
2673                let prop_nullable = prop_schema.is_nullable_any();
2674                let prop_description = prop_details.description.clone();
2675                let prop_default = prop_details.default.clone();
2676
2677                property_info.insert(
2678                    prop_name.clone(),
2679                    PropertyInfo {
2680                        schema_type: prop_type,
2681                        nullable: prop_nullable,
2682                        description: prop_description,
2683                        default: prop_default,
2684                        serde_attrs: Vec::new(),
2685                        constraints: PropertyConstraints::from_schema_details(prop_details),
2686                    },
2687                );
2688            }
2689        }
2690
2691        // Q2.3: classify additionalProperties three ways. When the
2692        // spec gives us a schema we analyze it and emit a typed
2693        // BTreeMap<String, T>; pre-Q2.3 collapsed both Schema and
2694        // Boolean(true) to the same untyped map. Toggle:
2695        //   [generator.types.shape] additional_properties_typed
2696        // Default true; setting false reverts the schema case to
2697        // Untyped (current pre-Q2.3 behavior).
2698        let typed_enabled = self
2699            .type_mapper
2700            .config()
2701            .shape
2702            .as_ref()
2703            .and_then(|s| s.additional_properties_typed)
2704            .unwrap_or(true);
2705
2706        let additional_properties = match &details.additional_properties {
2707            Some(crate::openapi::AdditionalProperties::Boolean(true)) => {
2708                ObjectAdditionalProperties::Untyped
2709            }
2710            Some(crate::openapi::AdditionalProperties::Boolean(false)) => {
2711                ObjectAdditionalProperties::Forbidden
2712            }
2713            Some(crate::openapi::AdditionalProperties::Schema(value_schema)) if typed_enabled => {
2714                let analyzed =
2715                    self.analyze_property_schema_with_context(value_schema, None, dependencies)?;
2716                ObjectAdditionalProperties::Typed {
2717                    value_type: Box::new(analyzed),
2718                }
2719            }
2720            Some(crate::openapi::AdditionalProperties::Schema(_)) => {
2721                // typed_enabled = false: degrade to the pre-Q2.3 behavior.
2722                ObjectAdditionalProperties::Untyped
2723            }
2724            None => ObjectAdditionalProperties::Forbidden,
2725        };
2726
2727        Ok(SchemaType::Object {
2728            properties: property_info,
2729            variant: None,
2730            required,
2731            additional_properties,
2732        })
2733    }
2734
2735    /// Build one union variant for a genuine `type: [X, Y, ...]` member.
2736    /// All members of a `TypedMulti` share a single `SchemaDetails`, so
2737    /// `array`/`object` members carry the *same* `items`/`properties` as
2738    /// the union schema itself — routing them through `TypeMapper::map`
2739    /// (as the scalar members are) would discard that shape and collapse
2740    /// to generic `Vec<serde_json::Value>` / `serde_json::Value`.
2741    ///
2742    /// This just properly handles array and object types before passing on to
2743    /// the type mapper.
2744    fn build_typed_multi_union_variant(
2745        &mut self,
2746        member_type: OpenApiSchemaType,
2747        schema: &Schema,
2748        union_type_name: &str,
2749        dependencies: &mut HashSet<String>,
2750    ) -> Result<SchemaRef> {
2751        match member_type {
2752            OpenApiSchemaType::Array => {
2753                let array_type_name = format!("{union_type_name}Array");
2754                let array_type =
2755                    self.analyze_array_schema(schema, &array_type_name, dependencies)?;
2756                self.resolved_cache.insert(
2757                    array_type_name.clone(),
2758                    AnalyzedSchema {
2759                        name: array_type_name.clone(),
2760                        original: serde_json::to_value(schema).unwrap_or(Value::Null),
2761                        schema_type: array_type,
2762                        dependencies: HashSet::new(),
2763                        nullable: false,
2764                        description: Some("Array variant in union".to_string()),
2765                        default: None,
2766                    },
2767                );
2768                dependencies.insert(array_type_name.clone());
2769                Ok(SchemaRef {
2770                    target: array_type_name,
2771                    nullable: false,
2772                })
2773            }
2774            OpenApiSchemaType::Object => {
2775                let object_type_name = format!("{union_type_name}Object");
2776                let object_type = self.analyze_object_schema(schema, dependencies)?;
2777                self.resolved_cache.insert(
2778                    object_type_name.clone(),
2779                    AnalyzedSchema {
2780                        name: object_type_name.clone(),
2781                        original: serde_json::to_value(schema).unwrap_or(Value::Null),
2782                        schema_type: object_type,
2783                        dependencies: dependencies.clone(),
2784                        nullable: false,
2785                        description: schema.details().description.clone(),
2786                        default: None,
2787                    },
2788                );
2789                dependencies.insert(object_type_name.clone());
2790                Ok(SchemaRef {
2791                    target: object_type_name,
2792                    nullable: false,
2793                })
2794            }
2795            _ => Ok(SchemaRef {
2796                target: self
2797                    .type_mapper
2798                    .map(member_type, schema.details())
2799                    .rust_type,
2800                nullable: false,
2801            }),
2802        }
2803    }
2804
2805    fn analyze_property_schema_with_context(
2806        &mut self,
2807        schema: &Schema,
2808        property_name: Option<&str>,
2809        dependencies: &mut HashSet<String>,
2810    ) -> Result<SchemaType> {
2811        if let Some(ref_str) = self.get_any_reference(schema) {
2812            let target_opt = if ref_str == "#" {
2813                Some(
2814                    self.find_recursive_anchor_schema()
2815                        .unwrap_or_else(|| "UnknownRecursive".to_string()),
2816                )
2817            } else {
2818                self.extract_schema_name(ref_str).map(|s| s.to_string())
2819            };
2820            match target_opt {
2821                Some(target) => {
2822                    dependencies.insert(target.clone());
2823                    return Ok(SchemaType::Reference { target });
2824                }
2825                None => {
2826                    // Not a component schema, but possibly still a local
2827                    // pointer into one: specs reference a parameter's schema
2828                    // (`#/components/parameters/x/schema`) or a member of a
2829                    // composition (`#/components/schemas/Tag/allOf/0`).
2830                    if let Some(resolved) = self.resolve_pointer_schema(ref_str, dependencies)? {
2831                        return Ok(resolved);
2832                    }
2833                    eprintln!(
2834                        "⚠️  unresolvable $ref `{}` — typing as serde_json::Value",
2835                        ref_str
2836                    );
2837                    return Ok(self.untyped_value(
2838                        format!("$ref {ref_str}"),
2839                        UntypedReason::UnresolvedReference,
2840                    ));
2841                }
2842            }
2843        }
2844
2845        // Genuine multi-scalar `type: [X, Y]` union (not the 3.1 nullable
2846        // shorthand `[X, "null"]`, which `schema_type()` already collapses).
2847        // Give it a named enum, same as an anyOf/oneOf union property below.
2848        if let Some(non_null_types) = schema.non_null_schema_types() {
2849            let context_name = self
2850                .current_schema_name
2851                .clone()
2852                .unwrap_or_else(|| "Unknown".to_string());
2853            let prop_pascal = property_name
2854                .map(|name| self.to_pascal_case(name))
2855                .unwrap_or_default();
2856            let mut union_type_name = format!("{context_name}{prop_pascal}");
2857            if self.schemas.contains_key(&union_type_name)
2858                || self.resolved_cache.contains_key(&union_type_name)
2859            {
2860                let mut suffix = 2;
2861                loop {
2862                    let candidate = format!("{union_type_name}Union{suffix}");
2863                    if !self.schemas.contains_key(&candidate)
2864                        && !self.resolved_cache.contains_key(&candidate)
2865                    {
2866                        union_type_name = candidate;
2867                        break;
2868                    }
2869                    suffix += 1;
2870                    if suffix > 1000 {
2871                        break;
2872                    }
2873                }
2874            }
2875
2876            let details = schema.details();
2877            let mut variants = Vec::with_capacity(non_null_types.len());
2878            for t in non_null_types {
2879                variants.push(self.build_typed_multi_union_variant(
2880                    t,
2881                    schema,
2882                    &union_type_name,
2883                    dependencies,
2884                )?);
2885            }
2886
2887            self.resolved_cache.insert(
2888                union_type_name.clone(),
2889                AnalyzedSchema {
2890                    name: union_type_name.clone(),
2891                    original: serde_json::to_value(schema).unwrap_or(Value::Null),
2892                    schema_type: SchemaType::Union { variants },
2893                    dependencies: HashSet::new(),
2894                    nullable: false,
2895                    description: details.description.clone(),
2896                    default: None,
2897                },
2898            );
2899
2900            dependencies.insert(union_type_name.clone());
2901            return Ok(SchemaType::Reference {
2902                target: union_type_name,
2903            });
2904        }
2905
2906        if let Some(schema_type) = schema.schema_type() {
2907            match schema_type {
2908                OpenApiSchemaType::String => {
2909                    // Check if this string type has enum values
2910                    if let Some(enum_values) = schema.details().string_enum_values() {
2911                        // This is an inline enum in a property - create a named enum type
2912                        // Use the current schema name as context to make the enum name unique
2913                        let context_name = self
2914                            .current_schema_name
2915                            .clone()
2916                            .unwrap_or_else(|| "Unknown".to_string());
2917
2918                        // Generate a candidate name based on both the schema and property context.
2919                        let primary_name = if let Some(prop_name) = property_name {
2920                            // We have property name context - use it for a unique name
2921                            let prop_pascal = self.to_pascal_case(prop_name);
2922                            format!("{context_name}{prop_pascal}")
2923                        } else {
2924                            // No property name context - generate a unique name using enum values
2925                            // Use the first enum value to help make the name unique
2926                            let suffix = if !enum_values.is_empty() {
2927                                let first_value = self.to_pascal_case(&enum_values[0]);
2928                                format!("{first_value}Enum")
2929                            } else {
2930                                "StringEnum".to_string()
2931                            };
2932                            format!("{context_name}{suffix}")
2933                        };
2934
2935                        return Ok(self.hoist_inline_string_enum(
2936                            schema,
2937                            enum_values,
2938                            primary_name,
2939                            dependencies,
2940                        ));
2941                    } else {
2942                        // Property-level string with no enum values:
2943                        // route through TypeMapper so `format: date-time`
2944                        // / `uuid` / etc. surface as typed scalars
2945                        // (chrono::DateTime, uuid::Uuid, …) instead of
2946                        // collapsing to bare `String`.
2947                        let mapped = self
2948                            .type_mapper
2949                            .string_format(schema.details().format.as_deref());
2950                        return Ok(SchemaType::Primitive {
2951                            rust_type: mapped.rust_type,
2952                            serde_with: mapped.serde_with,
2953                        });
2954                    }
2955                }
2956                OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
2957                    let details = schema.details();
2958                    let rust_type = self.get_number_rust_type(schema_type.clone(), details);
2959                    return Ok(SchemaType::Primitive {
2960                        rust_type,
2961                        serde_with: None,
2962                    });
2963                }
2964                OpenApiSchemaType::Boolean => {
2965                    return Ok(SchemaType::Primitive {
2966                        rust_type: "bool".to_string(),
2967                        serde_with: None,
2968                    });
2969                }
2970                OpenApiSchemaType::Array => {
2971                    // Analyze array property with context
2972                    let context_name = if let Some(prop_name) = property_name {
2973                        // Use property name for context
2974                        let prop_pascal = self.to_pascal_case(prop_name);
2975                        format!(
2976                            "{}{}",
2977                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2978                            prop_pascal
2979                        )
2980                    } else {
2981                        // Fallback to generic name
2982                        "ArrayItem".to_string()
2983                    };
2984                    return self.analyze_array_schema(schema, &context_name, dependencies);
2985                }
2986                OpenApiSchemaType::Object => {
2987                    // Check if this is a dynamic JSON object
2988                    if self.should_use_dynamic_json(schema) {
2989                        return Ok(self
2990                            .untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject));
2991                    }
2992                    // Inline object in property - create a named schema for it
2993                    let object_type_name = if let Some(prop_name) = property_name {
2994                        // Use property name for context
2995                        let prop_pascal = self.to_pascal_case(prop_name);
2996                        format!(
2997                            "{}{}",
2998                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2999                            prop_pascal
3000                        )
3001                    } else {
3002                        // Fallback to generic name
3003                        format!(
3004                            "{}Object",
3005                            self.current_schema_name.as_deref().unwrap_or("Unknown")
3006                        )
3007                    };
3008
3009                    // Analyze the object schema
3010                    let object_type = self.analyze_object_schema(schema, dependencies)?;
3011
3012                    // Create an analyzed schema for the inline object
3013                    let inline_schema = AnalyzedSchema {
3014                        name: object_type_name.clone(),
3015                        original: serde_json::to_value(schema).unwrap_or(Value::Null),
3016                        schema_type: object_type,
3017                        dependencies: dependencies.clone(),
3018                        nullable: false,
3019                        description: schema.details().description.clone(),
3020                        default: None,
3021                    };
3022
3023                    // Add the inline object as a named schema
3024                    self.resolved_cache
3025                        .insert(object_type_name.clone(), inline_schema);
3026                    dependencies.insert(object_type_name.clone());
3027
3028                    // Return a reference to the named schema
3029                    return Ok(SchemaType::Reference {
3030                        target: object_type_name,
3031                    });
3032                }
3033                // `type: null` admits exactly one value; Rust spells that
3034                // `()`, which serde reads from and writes as null.
3035                OpenApiSchemaType::Null => {
3036                    return Ok(SchemaType::Primitive {
3037                        rust_type: self.type_mapper.null_unit().rust_type,
3038                        serde_with: None,
3039                    });
3040                }
3041            }
3042        }
3043
3044        // Handle nullable patterns
3045        if schema.is_nullable_pattern() {
3046            if let Some(non_null) = schema.non_null_variant() {
3047                return self.analyze_property_schema_with_context(
3048                    non_null,
3049                    property_name,
3050                    dependencies,
3051                );
3052            }
3053        }
3054
3055        // Check if this should be dynamic JSON before further analysis
3056        if self.should_use_dynamic_json(schema) {
3057            return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject));
3058        }
3059
3060        // Handle allOf composition patterns
3061        if let Schema::AllOf { all_of, .. } = schema {
3062            return self.analyze_allof_composition(all_of, dependencies);
3063        }
3064
3065        // Handle union patterns (anyOf/oneOf) that weren't caught earlier
3066        if let Some(variants) = schema.union_variants() {
3067            match variants.len().cmp(&1) {
3068                std::cmp::Ordering::Equal => {
3069                    // Single variant - analyze it directly
3070                    return self.analyze_property_schema_with_context(
3071                        &variants[0],
3072                        property_name,
3073                        dependencies,
3074                    );
3075                }
3076                std::cmp::Ordering::Greater => {
3077                    // Multiple variants - try to analyze as a union
3078                    // Generate a context-aware name for the union type
3079                    let union_name = if let Some(prop_name) = property_name {
3080                        // We have property context - create a proper union name
3081                        let prop_pascal = self.to_pascal_case(prop_name);
3082                        format!(
3083                            "{}{}",
3084                            self.current_schema_name.as_deref().unwrap_or(""),
3085                            prop_pascal
3086                        )
3087                    } else {
3088                        "UnionType".to_string()
3089                    };
3090
3091                    // Check if this is a oneOf or anyOf
3092                    if let Schema::OneOf {
3093                        one_of,
3094                        discriminator,
3095                        ..
3096                    } = schema
3097                    {
3098                        // This is a oneOf - analyze it properly with potential discriminator
3099                        let oneof_result = self.analyze_oneof_union(
3100                            one_of,
3101                            discriminator.as_ref(),
3102                            &union_name,
3103                            dependencies,
3104                        )?;
3105
3106                        // If we got a union type (not discriminated), we need to store it as a named type
3107                        if let SchemaType::Union {
3108                            variants: _union_variants,
3109                        } = &oneof_result
3110                        {
3111                            // Store the union as a named type in resolved_cache
3112                            self.resolved_cache.insert(
3113                                union_name.clone(),
3114                                AnalyzedSchema {
3115                                    name: union_name.clone(),
3116                                    original: serde_json::to_value(schema).unwrap_or(Value::Null),
3117                                    schema_type: oneof_result.clone(),
3118                                    dependencies: dependencies.clone(),
3119                                    nullable: false,
3120                                    description: schema.details().description.clone(),
3121                                    default: None,
3122                                },
3123                            );
3124
3125                            // Return a reference to the named union type
3126                            dependencies.insert(union_name.clone());
3127                            return Ok(SchemaType::Reference { target: union_name });
3128                        }
3129
3130                        return Ok(oneof_result);
3131                    } else if let Schema::AnyOf {
3132                        any_of,
3133                        discriminator,
3134                        ..
3135                    } = schema
3136                    {
3137                        // This is anyOf - use existing logic with discriminator support
3138                        let union_analysis = self.analyze_anyof_union(
3139                            any_of,
3140                            discriminator.as_ref(),
3141                            dependencies,
3142                            &union_name,
3143                        )?;
3144                        return Ok(union_analysis);
3145                    } else {
3146                        // This shouldn't happen, but handle gracefully
3147                        // Create a simple union from variants
3148                        let mut union_variants = Vec::new();
3149                        for variant in variants {
3150                            if let Some(ref_str) = variant.reference() {
3151                                if let Some(target) = self.extract_schema_name(ref_str) {
3152                                    dependencies.insert(target.to_string());
3153                                    union_variants.push(SchemaRef {
3154                                        target: target.to_string(),
3155                                        nullable: false,
3156                                    });
3157                                }
3158                            }
3159                        }
3160                        return Ok(SchemaType::Union {
3161                            variants: union_variants,
3162                        });
3163                    }
3164                }
3165                std::cmp::Ordering::Less => {}
3166            }
3167        }
3168
3169        // Handle untyped schemas by trying to infer from structure
3170        if let Some(inferred_type) = schema.inferred_type() {
3171            match inferred_type {
3172                OpenApiSchemaType::Object => {
3173                    // Double-check for dynamic JSON pattern even for inferred objects
3174                    if self.should_use_dynamic_json(schema) {
3175                        return Ok(self
3176                            .untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject));
3177                    }
3178                    return self.analyze_object_schema(schema, dependencies);
3179                }
3180                OpenApiSchemaType::Array => {
3181                    let context_name = if let Some(prop_name) = property_name {
3182                        // Use property name for context
3183                        let prop_pascal = self.to_pascal_case(prop_name);
3184                        format!(
3185                            "{}{}",
3186                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
3187                            prop_pascal
3188                        )
3189                    } else {
3190                        // Fallback to generic name
3191                        "ArrayItem".to_string()
3192                    };
3193                    return self.analyze_array_schema(schema, &context_name, dependencies);
3194                }
3195                OpenApiSchemaType::String => {
3196                    if let Some(enum_values) = schema.details().string_enum_values() {
3197                        return Ok(SchemaType::StringEnum {
3198                            values: enum_values,
3199                        });
3200                    } else {
3201                        return Ok(SchemaType::Primitive {
3202                            rust_type: "String".to_string(),
3203                            serde_with: None,
3204                        });
3205                    }
3206                }
3207                _ => {
3208                    // Handle other inferred types
3209                    let rust_type = self.openapi_type_to_rust_type(inferred_type, schema.details());
3210                    return Ok(SchemaType::Primitive {
3211                        rust_type,
3212                        serde_with: None,
3213                    });
3214                }
3215            }
3216        }
3217
3218        Ok(self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema))
3219    }
3220
3221    fn analyze_allof_composition(
3222        &mut self,
3223        all_of_schemas: &[Schema],
3224        dependencies: &mut HashSet<String>,
3225    ) -> Result<SchemaType> {
3226        // A reference plus annotation-only siblings is still a direct type
3227        // alias. AWS-style specs frequently encode property descriptions as
3228        // `allOf: [$ref, { description: ... }]`; recursively expanding a
3229        // self-reference in that shape can otherwise recurse forever.
3230        let referenced_targets = all_of_schemas
3231            .iter()
3232            .filter_map(|schema| schema.reference())
3233            .filter_map(|reference| self.extract_schema_name(reference))
3234            .collect::<Vec<_>>();
3235        let only_reference_and_annotations = all_of_schemas.iter().all(|schema| {
3236            if schema.reference().is_some() {
3237                return true;
3238            }
3239            serde_json::to_value(schema)
3240                .ok()
3241                .and_then(|value| value.as_object().cloned())
3242                .is_some_and(|object| {
3243                    object.keys().all(|key| {
3244                        matches!(
3245                            key.as_str(),
3246                            "title"
3247                                | "description"
3248                                | "deprecated"
3249                                | "readOnly"
3250                                | "writeOnly"
3251                                | "examples"
3252                                | "example"
3253                                | "externalDocs"
3254                                | "xml"
3255                                | "$comment"
3256                        ) || key.starts_with("x-")
3257                    })
3258                })
3259        });
3260        if referenced_targets.len() == 1 && only_reference_and_annotations {
3261            let target = referenced_targets[0];
3262            dependencies.insert(target.to_string());
3263            return Ok(SchemaType::Reference {
3264                target: target.to_string(),
3265            });
3266        }
3267
3268        // A single member composes with nothing: `allOf: [{type: string}]` is
3269        // that string. Specs write it to hang a description off a scalar or a
3270        // `$ref`, and merging it as an object would lose the type entirely.
3271        if let [only] = all_of_schemas
3272            && !matches!(
3273                only.schema_type(),
3274                Some(OpenApiSchemaType::Object) | Some(OpenApiSchemaType::Null)
3275            )
3276            && only.details().properties.is_none()
3277        {
3278            return self.analyze_property_schema_with_context(only, None, dependencies);
3279        }
3280
3281        // AllOf represents schema composition - merge all schemas into one
3282        let mut merged_properties = BTreeMap::new();
3283        let mut merged_required = HashSet::new();
3284        let mut descriptions = Vec::new();
3285
3286        // Save the current schema context to restore it when analyzing properties
3287        let current_context = self.current_schema_name.clone();
3288
3289        for schema in all_of_schemas {
3290            match schema {
3291                Schema::Reference { reference, .. } => {
3292                    // Add dependency on referenced schema
3293                    if let Some(target) = self.extract_schema_name(reference) {
3294                        dependencies.insert(target.to_string());
3295
3296                        // First ensure the referenced schema is analyzed
3297                        let analyzed_ref = self.analyze_schema(target)?;
3298
3299                        // Now merge the analyzed schema's properties
3300                        match &analyzed_ref.schema_type {
3301                            SchemaType::Object {
3302                                properties,
3303                                required,
3304                                ..
3305                            } => {
3306                                // Merge properties from the analyzed schema
3307                                for (prop_name, prop_info) in properties {
3308                                    merged_properties.insert(prop_name.clone(), prop_info.clone());
3309                                }
3310                                // Merge required fields
3311                                for req in required {
3312                                    merged_required.insert(req.clone());
3313                                }
3314                            }
3315                            _ => {
3316                                // If the referenced schema is not an object, fall back to raw merge
3317                                if let Some(ref_schema) = self.schemas.get(target).cloned() {
3318                                    self.merge_schema_into_properties(
3319                                        &ref_schema,
3320                                        &mut merged_properties,
3321                                        &mut merged_required,
3322                                        dependencies,
3323                                    )?;
3324                                }
3325                            }
3326                        }
3327                    }
3328                }
3329                Schema::Typed {
3330                    schema_type: OpenApiSchemaType::Object,
3331                    ..
3332                }
3333                | Schema::Untyped { .. } => {
3334                    // Restore the original context when analyzing inline properties
3335                    let saved_context = self.current_schema_name.clone();
3336                    self.current_schema_name = current_context.clone();
3337
3338                    // Merge object properties directly
3339                    self.merge_schema_into_properties(
3340                        schema,
3341                        &mut merged_properties,
3342                        &mut merged_required,
3343                        dependencies,
3344                    )?;
3345
3346                    // Restore the previous context
3347                    self.current_schema_name = saved_context;
3348                }
3349                _ => {
3350                    // For non-object typed schemas in allOf, try to merge them as well
3351                    // This handles cases like allOf with enum or string constraints
3352                    self.merge_schema_into_properties(
3353                        schema,
3354                        &mut merged_properties,
3355                        &mut merged_required,
3356                        dependencies,
3357                    )?;
3358                }
3359            }
3360
3361            // Collect descriptions
3362            if let Some(desc) = &schema.details().description {
3363                descriptions.push(desc.clone());
3364            }
3365        }
3366
3367        // If we successfully merged properties, return an object
3368        if !merged_properties.is_empty() {
3369            Ok(SchemaType::Object {
3370                properties: merged_properties,
3371                required: merged_required,
3372                additional_properties: ObjectAdditionalProperties::Forbidden,
3373                variant: None,
3374            })
3375        } else {
3376            // Fall back to composition if we couldn't merge
3377            Ok(SchemaType::Composition {
3378                schemas: all_of_schemas
3379                    .iter()
3380                    .filter_map(|s| {
3381                        if let Some(ref_str) = s.reference() {
3382                            if let Some(target) = self.extract_schema_name(ref_str) {
3383                                dependencies.insert(target.to_string());
3384                                Some(SchemaRef {
3385                                    target: target.to_string(),
3386                                    nullable: false,
3387                                })
3388                            } else {
3389                                None
3390                            }
3391                        } else {
3392                            None
3393                        }
3394                    })
3395                    .collect(),
3396            })
3397        }
3398    }
3399
3400    fn merge_schema_into_properties(
3401        &mut self,
3402        schema: &Schema,
3403        merged_properties: &mut BTreeMap<String, PropertyInfo>,
3404        merged_required: &mut HashSet<String>,
3405        dependencies: &mut HashSet<String>,
3406    ) -> Result<()> {
3407        let details = schema.details();
3408
3409        // Merge properties
3410        if let Some(properties) = &details.properties {
3411            for (prop_name, prop_schema) in properties {
3412                let prop_type = self.analyze_property_schema_with_context(
3413                    prop_schema,
3414                    Some(prop_name),
3415                    dependencies,
3416                )?;
3417                let owner_name = self
3418                    .current_schema_name
3419                    .clone()
3420                    .unwrap_or_else(|| "Inline".to_string());
3421                let prop_type = self.hoist_inline_property_type(
3422                    &owner_name,
3423                    prop_name,
3424                    prop_type,
3425                    dependencies,
3426                );
3427                let prop_details = prop_schema.details();
3428
3429                // Properties merged through allOf composition must go through
3430                // the same nullability check as plain object properties.
3431                // Real hits: OpenAI Response.incomplete_details (anyOf-with-null,
3432                // openapi-generator-bgo) and RunPod Pod.startedAt / Pod.template
3433                // (3.1 type-array, openapi-generator-dsu) — the latter arrive
3434                // as `null` from the live API for any pod that hasn't started.
3435                let nullable = prop_schema.is_nullable_any();
3436                merged_properties.insert(
3437                    prop_name.clone(),
3438                    PropertyInfo {
3439                        schema_type: prop_type,
3440                        nullable,
3441                        description: prop_details.description.clone(),
3442                        default: prop_details.default.clone(),
3443                        serde_attrs: Vec::new(),
3444                        constraints: PropertyConstraints::from_schema_details(prop_details),
3445                    },
3446                );
3447            }
3448        }
3449
3450        // Merge required fields
3451        if let Some(required) = &details.required {
3452            for field in required {
3453                merged_required.insert(field.clone());
3454            }
3455        }
3456
3457        Ok(())
3458    }
3459
3460    fn analyze_oneof_union(
3461        &mut self,
3462        one_of_schemas: &[Schema],
3463        discriminator: Option<&crate::openapi::Discriminator>,
3464        parent_name: &str,
3465        dependencies: &mut HashSet<String>,
3466    ) -> Result<SchemaType> {
3467        // Branches may be pointers into other parts of the document.
3468        let expanded_branches;
3469        let one_of_schemas = match self.expand_pointer_branches(one_of_schemas) {
3470            Some(expanded) => {
3471                expanded_branches = expanded;
3472                expanded_branches.as_slice()
3473            }
3474            None => one_of_schemas,
3475        };
3476
3477        // A union of one is that one, and branches that differ only in
3478        // constraints share one Rust type. Both are checked before the shape
3479        // patterns below, which would otherwise synthesize a union type and
3480        // then fail to represent it.
3481        if let [only] = one_of_schemas {
3482            return self
3483                .analyze_schema_value(only, parent_name)
3484                .map(|analyzed| analyzed.schema_type);
3485        }
3486        if let Some(shared) = self.shared_branch_type(one_of_schemas) {
3487            return Ok(shared);
3488        }
3489
3490        // Pattern: nullable [Type, null] — return the non-null type directly.
3491        // The nullable bit is recorded at the property level via is_nullable_pattern().
3492        if one_of_schemas.len() == 2 {
3493            let null_count = one_of_schemas
3494                .iter()
3495                .filter(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
3496                .count();
3497            if null_count == 1 {
3498                if let Some(non_null) = one_of_schemas
3499                    .iter()
3500                    .find(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
3501                {
3502                    return self
3503                        .analyze_schema_value(non_null, parent_name)
3504                        .map(|a| a.schema_type);
3505                }
3506            }
3507        }
3508
3509        // If there's no discriminator, we should create an untagged union
3510        if discriminator.is_none() {
3511            // Handle untagged unions (oneOf without discriminator)
3512            return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
3513        }
3514
3515        // Bug openapi-generator-dpd: if any branch resolves to a non-object
3516        // schema (e.g. a string-enum like ToolChoiceOptions), serde cannot
3517        // deserialize it via an internally-tagged enum because there is no
3518        // JSON object to read the tag from. Fall back to an untagged union
3519        // so the scalar branch can still match.
3520        if one_of_schemas
3521            .iter()
3522            .any(|s| !self.branch_resolves_to_object(s))
3523        {
3524            return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
3525        }
3526
3527        // This is a discriminated union
3528        let discriminator_field = discriminator
3529            .ok_or_else(|| {
3530                GeneratorError::InvalidDiscriminator(
3531                    "expected discriminator after guard check".to_string(),
3532                )
3533            })?
3534            .property_name
3535            .clone();
3536
3537        let mut variants = Vec::new();
3538        let mut used_variant_names = std::collections::HashSet::new();
3539
3540        for variant_schema in one_of_schemas {
3541            // Check if this is a direct reference, recursive reference, or an allOf wrapper with a reference
3542            let ref_info = if let Some(ref_str) = variant_schema.reference() {
3543                Some((ref_str, false))
3544            } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
3545                Some((recursive_ref, true))
3546            } else if let Schema::AllOf { all_of, .. } = variant_schema {
3547                // Check if this is an allOf with a single reference
3548                if all_of.len() == 1 {
3549                    if let Some(ref_str) = all_of[0].reference() {
3550                        Some((ref_str, false))
3551                    } else {
3552                        all_of[0]
3553                            .recursive_reference()
3554                            .map(|recursive_ref| (recursive_ref, true))
3555                    }
3556                } else {
3557                    None
3558                }
3559            } else {
3560                None
3561            };
3562
3563            if let Some((ref_str, is_recursive)) = ref_info {
3564                let schema_name = if is_recursive && ref_str == "#" {
3565                    // Handle recursive reference to the schema with recursiveAnchor
3566                    self.find_recursive_anchor_schema()
3567                        .or_else(|| self.current_schema_name.clone())
3568                        .unwrap_or_else(|| "CompoundFilter".to_string())
3569                } else {
3570                    self.extract_schema_name(ref_str)
3571                        .map(|s| s.to_string())
3572                        .unwrap_or_else(|| "UnknownRef".to_string())
3573                };
3574
3575                if !schema_name.is_empty() {
3576                    dependencies.insert(schema_name.clone());
3577
3578                    // Determine discriminator value with priority order:
3579                    // 1. Explicit mapping in discriminator
3580                    // 2. Extract from referenced schema
3581                    // 3. Generate from schema name
3582                    let discriminator_value = if let Some(disc) = discriminator {
3583                        if let Some(mappings) = &disc.mapping {
3584                            // Find the mapping key that points to this schema reference
3585                            // Mapping format is: "discriminator_value" -> "#/components/schemas/SchemaName"
3586                            mappings
3587                                .iter()
3588                                .find(|(_, target_ref)| {
3589                                    // Check if this mapping target matches our reference
3590                                    target_ref.as_str() == ref_str
3591                                        || self
3592                                            .extract_schema_name(target_ref)
3593                                            .map(|s| s.to_string())
3594                                            == Some(schema_name.clone())
3595                                })
3596                                .map(|(key, _)| key.clone())
3597                                .unwrap_or_else(|| {
3598                                    self.fallback_discriminator_value_for_field(
3599                                        &schema_name,
3600                                        &discriminator_field,
3601                                    )
3602                                })
3603                        } else {
3604                            self.fallback_discriminator_value_for_field(
3605                                &schema_name,
3606                                &discriminator_field,
3607                            )
3608                        }
3609                    } else {
3610                        self.fallback_discriminator_value_for_field(
3611                            &schema_name,
3612                            &discriminator_field,
3613                        )
3614                    };
3615
3616                    // Generate Rust-friendly variant name and ensure uniqueness
3617                    let base_name = self.to_rust_variant_name(&schema_name);
3618                    let rust_name =
3619                        self.ensure_unique_variant_name(base_name, &mut used_variant_names);
3620
3621                    // Use the discriminator value as-is from the schema
3622                    let final_discriminator_value = discriminator_value;
3623
3624                    variants.push(UnionVariant {
3625                        rust_name,
3626                        type_name: schema_name,
3627                        discriminator_value: final_discriminator_value,
3628                        schema_ref: ref_str.to_string(),
3629                    });
3630                }
3631            } else {
3632                // Handle inline schemas in oneOf
3633                let variant_index = variants.len();
3634                let inline_type_name =
3635                    self.generate_inline_type_name(variant_schema, variant_index);
3636
3637                // Try to extract discriminator value from inline schema
3638                let discriminator_value = if let Some(disc) = discriminator {
3639                    if let Some(mappings) = &disc.mapping {
3640                        // Look for mapping that points to this inline variant by index
3641                        mappings
3642                            .iter()
3643                            .find(|(_, target_ref)| {
3644                                target_ref.contains(&format!("variant_{variant_index}"))
3645                            })
3646                            .map(|(key, _)| key.clone())
3647                            .unwrap_or_else(|| {
3648                                self.extract_inline_discriminator_value(
3649                                    variant_schema,
3650                                    &discriminator_field,
3651                                    variant_index,
3652                                )
3653                            })
3654                    } else {
3655                        self.extract_inline_discriminator_value(
3656                            variant_schema,
3657                            &discriminator_field,
3658                            variant_index,
3659                        )
3660                    }
3661                } else {
3662                    self.extract_inline_discriminator_value(
3663                        variant_schema,
3664                        &discriminator_field,
3665                        variant_index,
3666                    )
3667                };
3668
3669                // Generate Rust-friendly variant name based on discriminator or fallback to generic
3670                let base_name = if discriminator_value.starts_with("variant_") {
3671                    format!("Variant{variant_index}")
3672                } else {
3673                    // Convert discriminator value to a meaningful Rust variant name
3674                    let clean_name = self.discriminator_to_variant_name(&discriminator_value);
3675                    self.to_rust_variant_name(&clean_name)
3676                };
3677                let rust_name = self.ensure_unique_variant_name(base_name, &mut used_variant_names);
3678
3679                // Use the discriminator value as-is from the schema
3680                let final_discriminator_value = discriminator_value;
3681
3682                variants.push(UnionVariant {
3683                    rust_name,
3684                    type_name: inline_type_name.clone(),
3685                    discriminator_value: final_discriminator_value,
3686                    schema_ref: format!("inline_{variant_index}"),
3687                });
3688
3689                // Store inline schema for later analysis and generation
3690                self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
3691            }
3692        }
3693
3694        if variants.is_empty() {
3695            // If we couldn't create a discriminated union, fall back to an untagged union
3696            // This handles cases where oneOf contains references or inline schemas without proper discriminators
3697            let mut union_variants = Vec::new();
3698
3699            for (variant_index, variant_schema) in one_of_schemas.iter().enumerate() {
3700                // First check if it's a reference or recursive reference
3701                if let Some(ref_str) = variant_schema.reference() {
3702                    if let Some(schema_name) = self.extract_schema_name(ref_str) {
3703                        dependencies.insert(schema_name.to_string());
3704                        union_variants.push(SchemaRef {
3705                            target: schema_name.to_string(),
3706                            nullable: false,
3707                        });
3708                    }
3709                } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
3710                    let schema_name = if recursive_ref == "#" {
3711                        // Handle recursive reference to the schema with recursiveAnchor
3712                        self.find_recursive_anchor_schema()
3713                            .or_else(|| self.current_schema_name.clone())
3714                            .unwrap_or_else(|| "CompoundFilter".to_string())
3715                    } else {
3716                        self.extract_schema_name(recursive_ref)
3717                            .map(|s| s.to_string())
3718                            .unwrap_or_else(|| "RecursiveType".to_string())
3719                    };
3720                    dependencies.insert(schema_name.clone());
3721                    union_variants.push(SchemaRef {
3722                        target: schema_name,
3723                        nullable: false,
3724                    });
3725                } else {
3726                    // Handle inline schemas by creating type aliases or using primitive types directly
3727                    let inline_name = self.generate_context_aware_name(
3728                        parent_name,
3729                        "InlineVariant",
3730                        variant_index,
3731                        Some(variant_schema),
3732                    );
3733                    let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
3734                    let variant_type = analyzed.schema_type;
3735
3736                    // Add dependencies from the analyzed schema
3737                    for dep in &analyzed.dependencies {
3738                        dependencies.insert(dep.clone());
3739                    }
3740
3741                    match &variant_type {
3742                        // For primitive types, we can use them directly in the union
3743                        SchemaType::Primitive { rust_type, .. } => {
3744                            union_variants.push(SchemaRef {
3745                                target: rust_type.clone(),
3746                                nullable: false,
3747                            });
3748                        }
3749                        // For arrays, check if we can determine the item type
3750                        SchemaType::Array { item_type } => {
3751                            match item_type.as_ref() {
3752                                SchemaType::Primitive { rust_type, .. } => {
3753                                    let type_name = format!("Vec<{rust_type}>");
3754                                    union_variants.push(SchemaRef {
3755                                        target: type_name,
3756                                        nullable: false,
3757                                    });
3758                                }
3759                                SchemaType::Reference { target } => {
3760                                    let type_name = format!("Vec<{target}>");
3761                                    union_variants.push(SchemaRef {
3762                                        target: type_name,
3763                                        nullable: false,
3764                                    });
3765                                }
3766                                _ => {
3767                                    // For other array types, create an inline type
3768                                    let inline_type_name = self.generate_context_aware_name(
3769                                        parent_name,
3770                                        "Variant",
3771                                        variant_index,
3772                                        None,
3773                                    );
3774                                    self.add_inline_schema(
3775                                        &inline_type_name,
3776                                        variant_schema,
3777                                        dependencies,
3778                                    )?;
3779                                    union_variants.push(SchemaRef {
3780                                        target: inline_type_name,
3781                                        nullable: false,
3782                                    });
3783                                }
3784                            }
3785                        }
3786                        // For reference types, use the reference target directly
3787                        SchemaType::Reference { target } => {
3788                            union_variants.push(SchemaRef {
3789                                target: target.clone(),
3790                                nullable: false,
3791                            });
3792                        }
3793                        // For other complex types, create an inline type
3794                        _ => {
3795                            let inline_type_name =
3796                                format!("{}Variant{}", parent_name, variant_index + 1);
3797                            self.add_inline_schema(
3798                                &inline_type_name,
3799                                variant_schema,
3800                                dependencies,
3801                            )?;
3802                            union_variants.push(SchemaRef {
3803                                target: inline_type_name,
3804                                nullable: false,
3805                            });
3806                        }
3807                    }
3808                }
3809            }
3810
3811            if !union_variants.is_empty() {
3812                return Ok(SchemaType::Union {
3813                    variants: union_variants,
3814                });
3815            }
3816
3817            // Only fall back to serde_json::Value if we truly can't analyze the union
3818            return Ok(self.untyped_value(
3819                self.untyped_context(""),
3820                UntypedReason::UnrepresentableUnion,
3821            ));
3822        }
3823
3824        Ok(SchemaType::DiscriminatedUnion {
3825            discriminator_field,
3826            variants,
3827        })
3828    }
3829
3830    fn analyze_untagged_oneof_union(
3831        &mut self,
3832        one_of_schemas: &[Schema],
3833        parent_name: &str,
3834        dependencies: &mut HashSet<String>,
3835    ) -> Result<SchemaType> {
3836        // Drop {"type": "null"} variants. They mean "may be null" and are surfaced
3837        // as Option<T> at the property level — including them here produces a junk
3838        // `SerdeJsonValue(serde_json::Value)` variant.
3839        let filtered: Vec<&Schema> = one_of_schemas
3840            .iter()
3841            .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
3842            .collect();
3843
3844        // If filtering leaves a single variant, return its analyzed type directly.
3845        if filtered.len() == 1 {
3846            return self
3847                .analyze_schema_value(filtered[0], parent_name)
3848                .map(|a| a.schema_type);
3849        }
3850
3851        let mut union_variants = Vec::new();
3852
3853        for (variant_index, variant_schema) in filtered.iter().copied().enumerate() {
3854            // First check if it's a reference or recursive reference
3855            if let Some(ref_str) = variant_schema.reference() {
3856                if let Some(schema_name) = self.extract_schema_name(ref_str) {
3857                    dependencies.insert(schema_name.to_string());
3858                    union_variants.push(SchemaRef {
3859                        target: schema_name.to_string(),
3860                        nullable: false,
3861                    });
3862                }
3863            } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
3864                let schema_name = if recursive_ref == "#" {
3865                    // Handle recursive reference to the schema with recursiveAnchor
3866                    self.find_recursive_anchor_schema()
3867                        .or_else(|| self.current_schema_name.clone())
3868                        .unwrap_or_else(|| "CompoundFilter".to_string())
3869                } else {
3870                    self.extract_schema_name(recursive_ref)
3871                        .map(|s| s.to_string())
3872                        .unwrap_or_else(|| "RecursiveType".to_string())
3873                };
3874                dependencies.insert(schema_name.clone());
3875                union_variants.push(SchemaRef {
3876                    target: schema_name,
3877                    nullable: false,
3878                });
3879            } else {
3880                // Handle inline schemas by creating type aliases or using primitive types directly
3881                let inline_name = self.generate_context_aware_name(
3882                    parent_name,
3883                    "InlineVariant",
3884                    variant_index,
3885                    Some(variant_schema),
3886                );
3887                let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
3888                let variant_type = analyzed.schema_type;
3889
3890                // Add dependencies from the analyzed schema
3891                for dep in &analyzed.dependencies {
3892                    dependencies.insert(dep.clone());
3893                }
3894
3895                match &variant_type {
3896                    // For primitive types, we can use them directly in the union
3897                    SchemaType::Primitive { rust_type, .. } => {
3898                        union_variants.push(SchemaRef {
3899                            target: rust_type.clone(),
3900                            nullable: false,
3901                        });
3902                    }
3903                    // For arrays, check if we can determine the item type
3904                    SchemaType::Array { item_type } => {
3905                        match item_type.as_ref() {
3906                            SchemaType::Primitive { rust_type, .. } => {
3907                                let type_name = format!("Vec<{rust_type}>");
3908                                union_variants.push(SchemaRef {
3909                                    target: type_name,
3910                                    nullable: false,
3911                                });
3912                            }
3913                            SchemaType::Reference { target } => {
3914                                let type_name = format!("Vec<{target}>");
3915                                union_variants.push(SchemaRef {
3916                                    target: type_name,
3917                                    nullable: false,
3918                                });
3919                            }
3920                            // Handle arrays of arrays (e.g., Vec<Vec<i64>>)
3921                            SchemaType::Array {
3922                                item_type: inner_item_type,
3923                            } => {
3924                                match inner_item_type.as_ref() {
3925                                    SchemaType::Primitive { rust_type, .. } => {
3926                                        let type_name = format!("Vec<Vec<{rust_type}>>");
3927                                        union_variants.push(SchemaRef {
3928                                            target: type_name,
3929                                            nullable: false,
3930                                        });
3931                                    }
3932                                    SchemaType::Reference { target } => {
3933                                        let type_name = format!("Vec<Vec<{target}>>");
3934                                        union_variants.push(SchemaRef {
3935                                            target: type_name,
3936                                            nullable: false,
3937                                        });
3938                                    }
3939                                    _ => {
3940                                        // For deeper nesting, create an inline type
3941                                        let inline_type_name = self.generate_context_aware_name(
3942                                            parent_name,
3943                                            "Variant",
3944                                            variant_index,
3945                                            None,
3946                                        );
3947                                        self.add_inline_schema(
3948                                            &inline_type_name,
3949                                            variant_schema,
3950                                            dependencies,
3951                                        )?;
3952                                        union_variants.push(SchemaRef {
3953                                            target: inline_type_name,
3954                                            nullable: false,
3955                                        });
3956                                    }
3957                                }
3958                            }
3959                            _ => {
3960                                // For other array types, create an inline type
3961                                let inline_type_name = self.generate_context_aware_name(
3962                                    parent_name,
3963                                    "Variant",
3964                                    variant_index,
3965                                    None,
3966                                );
3967                                self.add_inline_schema(
3968                                    &inline_type_name,
3969                                    variant_schema,
3970                                    dependencies,
3971                                )?;
3972                                union_variants.push(SchemaRef {
3973                                    target: inline_type_name,
3974                                    nullable: false,
3975                                });
3976                            }
3977                        }
3978                    }
3979                    // For reference types, use the reference target directly
3980                    SchemaType::Reference { target } => {
3981                        union_variants.push(SchemaRef {
3982                            target: target.clone(),
3983                            nullable: false,
3984                        });
3985                    }
3986                    // For other complex types, create an inline type
3987                    _ => {
3988                        let inline_type_name = self.generate_context_aware_name(
3989                            parent_name,
3990                            "Variant",
3991                            variant_index,
3992                            None,
3993                        );
3994                        self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
3995                        union_variants.push(SchemaRef {
3996                            target: inline_type_name,
3997                            nullable: false,
3998                        });
3999                    }
4000                }
4001            }
4002        }
4003
4004        if !union_variants.is_empty() {
4005            return Ok(SchemaType::Union {
4006                variants: union_variants,
4007            });
4008        }
4009
4010        // Only fall back to serde_json::Value if we truly can't analyze the union
4011        Ok(self.untyped_value(
4012            self.untyped_context(""),
4013            UntypedReason::UnrepresentableUnion,
4014        ))
4015    }
4016
4017    fn add_inline_schema(
4018        &mut self,
4019        type_name: &str,
4020        schema: &Schema,
4021        dependencies: &mut HashSet<String>,
4022    ) -> Result<()> {
4023        // For primitive types, we need to ensure they are stored as type aliases
4024        if let Some(schema_type) = schema.schema_type() {
4025            match schema_type {
4026                OpenApiSchemaType::String
4027                | OpenApiSchemaType::Integer
4028                | OpenApiSchemaType::Number
4029                | OpenApiSchemaType::Boolean => {
4030                    let rust_type =
4031                        self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
4032
4033                    // Store as a type alias
4034                    self.resolved_cache.insert(
4035                        type_name.to_string(),
4036                        AnalyzedSchema {
4037                            name: type_name.to_string(),
4038                            original: serde_json::to_value(schema).unwrap_or(Value::Null),
4039                            schema_type: SchemaType::Primitive {
4040                                rust_type,
4041                                serde_with: None,
4042                            },
4043                            dependencies: HashSet::new(),
4044                            nullable: false,
4045                            description: schema.details().description.clone(),
4046                            default: None,
4047                        },
4048                    );
4049                    return Ok(());
4050                }
4051                _ => {}
4052            }
4053        }
4054
4055        // For non-primitive types, analyze the inline schema and add it to our collection
4056        // Set current_schema_name so nested inline properties (enums, unions, objects)
4057        // get named with the correct parent context instead of inheriting a stale name
4058        let previous_schema_name = self.current_schema_name.take();
4059        self.current_schema_name = Some(type_name.to_string());
4060        let analyzed = self.analyze_schema_value(schema, type_name)?;
4061        self.current_schema_name = previous_schema_name;
4062
4063        // Add to resolved cache so it can be generated
4064        self.resolved_cache.insert(type_name.to_string(), analyzed);
4065
4066        // Add dependencies
4067        if let Some(cached) = self.resolved_cache.get(type_name) {
4068            for dep in &cached.dependencies {
4069                dependencies.insert(dep.clone());
4070            }
4071        }
4072
4073        Ok(())
4074    }
4075
4076    fn extract_inline_discriminator_value(
4077        &self,
4078        schema: &Schema,
4079        discriminator_field: &str,
4080        variant_index: usize,
4081    ) -> String {
4082        // Try to extract discriminator value from inline schema properties
4083        if let Some(properties) = &schema.details().properties {
4084            if let Some(discriminator_prop) = properties.get(discriminator_field) {
4085                // Check for enum with single value
4086                if let Some(enum_values) = &discriminator_prop.details().enum_values {
4087                    if enum_values.len() == 1 {
4088                        if let Some(value) = enum_values[0].as_str() {
4089                            return value.to_string();
4090                        }
4091                    }
4092                }
4093                // Check for const value in extra fields
4094                if let Some(const_value) = discriminator_prop.details().extra.get("const") {
4095                    if let Some(value) = const_value.as_str() {
4096                        return value.to_string();
4097                    }
4098                }
4099                // Check for const value in the discriminator_prop.details().const_value
4100                if let Some(const_value) = &discriminator_prop.details().const_value {
4101                    if let Some(value) = const_value.as_str() {
4102                        return value.to_string();
4103                    }
4104                }
4105            }
4106        }
4107
4108        // Try to infer from schema structure and properties
4109        if let Some(inferred_name) = self.infer_variant_name_from_structure(schema, variant_index) {
4110            return inferred_name;
4111        }
4112
4113        // Fall back to generic variant name
4114        format!("variant_{variant_index}")
4115    }
4116
4117    fn infer_variant_name_from_structure(
4118        &self,
4119        schema: &Schema,
4120        _variant_index: usize,
4121    ) -> Option<String> {
4122        let details = schema.details();
4123
4124        // Strategy 1: Look for unique property combinations that suggest the variant type
4125        if let Some(properties) = &details.properties {
4126            // Common patterns for content blocks
4127            if properties.contains_key("text") && properties.len() <= 3 {
4128                return Some("text".to_string());
4129            }
4130            if properties.contains_key("image") || properties.contains_key("source") {
4131                return Some("image".to_string());
4132            }
4133            if properties.contains_key("document") {
4134                return Some("document".to_string());
4135            }
4136            if properties.contains_key("tool_use_id") || properties.contains_key("tool_result") {
4137                return Some("tool_result".to_string());
4138            }
4139            if properties.contains_key("content") && properties.contains_key("is_error") {
4140                return Some("tool_result".to_string());
4141            }
4142            if properties.contains_key("partial_json") {
4143                return Some("partial_json".to_string());
4144            }
4145
4146            // Strategy 2: Look for properties that hint at the variant purpose
4147            let property_names: Vec<&String> = properties.keys().collect();
4148
4149            // Try to find the most descriptive property name
4150            for prop_name in &property_names {
4151                if prop_name.contains("result") {
4152                    return Some("result".to_string());
4153                }
4154                if prop_name.contains("error") {
4155                    return Some("error".to_string());
4156                }
4157                if prop_name.contains("content") && property_names.len() <= 2 {
4158                    return Some("content".to_string());
4159                }
4160            }
4161
4162            // Strategy 3: Use the most significant unique property
4163            let significant_props = property_names
4164                .iter()
4165                .filter(|&name| !["type", "id", "cache_control"].contains(&name.as_str()))
4166                .collect::<Vec<_>>();
4167
4168            if significant_props.len() == 1 {
4169                return Some((*significant_props[0]).clone());
4170            }
4171        }
4172
4173        // Strategy 4: Look at description for hints
4174        if let Some(description) = &details.description {
4175            let desc_lower = description.to_lowercase();
4176            if desc_lower.contains("text") && desc_lower.len() < 100 {
4177                return Some("text".to_string());
4178            }
4179            if desc_lower.contains("image") {
4180                return Some("image".to_string());
4181            }
4182            if desc_lower.contains("document") {
4183                return Some("document".to_string());
4184            }
4185            if desc_lower.contains("tool") && desc_lower.contains("result") {
4186                return Some("tool_result".to_string());
4187            }
4188        }
4189
4190        None
4191    }
4192
4193    fn discriminator_to_variant_name(&self, discriminator: &str) -> String {
4194        // Convert discriminator values to PascalCase variant names using general rules
4195        if discriminator.is_empty() {
4196            return "Variant".to_string();
4197        }
4198
4199        let mut result = String::new();
4200        let mut next_upper = true;
4201
4202        for c in discriminator.chars() {
4203            match c {
4204                'a'..='z' => {
4205                    if next_upper {
4206                        result.push(c.to_ascii_uppercase());
4207                        next_upper = false;
4208                    } else {
4209                        result.push(c);
4210                    }
4211                }
4212                'A'..='Z' => {
4213                    result.push(c);
4214                    next_upper = false;
4215                }
4216                '0'..='9' => {
4217                    result.push(c);
4218                    next_upper = false;
4219                }
4220                '_' | '-' | '.' | ' ' | '/' | '\\' => {
4221                    // Word separators - next char should be uppercase
4222                    next_upper = true;
4223                }
4224                _ => {
4225                    // Other special characters - treat as word boundary
4226                    next_upper = true;
4227                }
4228            }
4229        }
4230
4231        // Ensure it starts with a letter
4232        if result.is_empty() || result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
4233            result = format!("Variant{result}");
4234        }
4235
4236        result
4237    }
4238
4239    fn ensure_unique_variant_name(
4240        &self,
4241        base_name: String,
4242        used_names: &mut std::collections::HashSet<String>,
4243    ) -> String {
4244        let mut candidate = base_name.clone();
4245        let mut counter = 1;
4246
4247        while used_names.contains(&candidate) {
4248            counter += 1;
4249            candidate = format!("{base_name}{counter}");
4250        }
4251
4252        used_names.insert(candidate.clone());
4253        candidate
4254    }
4255
4256    fn generate_inline_type_name(&self, schema: &Schema, variant_index: usize) -> String {
4257        // Try to generate a meaningful name for inline schemas
4258        if let Some(meaningful_name) = self.infer_type_name_from_structure(schema) {
4259            return meaningful_name;
4260        }
4261
4262        // Fallback to context-aware name
4263        let context = self.current_schema_name.as_deref().unwrap_or("Inline");
4264        self.generate_context_aware_name(context, "Variant", variant_index, Some(schema))
4265    }
4266
4267    fn infer_type_name_from_structure(&self, schema: &Schema) -> Option<String> {
4268        let details = schema.details();
4269
4270        // Strategy 1: Use description if it's short and descriptive
4271        if let Some(description) = &details.description {
4272            if let Some(name_from_desc) = self.extract_type_name_from_description(description) {
4273                return Some(name_from_desc);
4274            }
4275        }
4276
4277        // Strategy 2: Use the most significant property name as the type identifier
4278        if let Some(properties) = &details.properties {
4279            if let Some(name_from_props) = self.extract_type_name_from_properties(properties) {
4280                return Some(format!("{name_from_props}Block"));
4281            }
4282        }
4283
4284        None
4285    }
4286
4287    fn extract_type_name_from_description(&self, description: &str) -> Option<String> {
4288        // Only use descriptions that are short and likely to be type identifiers
4289        if description.len() > 100 || description.contains('\n') {
4290            return None;
4291        }
4292
4293        // Extract the first meaningful word(s) from the description
4294        let words: Vec<&str> = description
4295            .split_whitespace()
4296            .take(2) // Only take first 2 words to avoid long names
4297            .filter(|word| {
4298                let w = word.to_lowercase();
4299                word.len() > 2
4300                    && ![
4301                        "the", "and", "for", "with", "that", "this", "are", "can", "will", "was",
4302                    ]
4303                    .contains(&w.as_str())
4304            })
4305            .collect();
4306
4307        if words.is_empty() {
4308            return None;
4309        }
4310
4311        // Convert to PascalCase using our existing logic
4312        let combined = words.join("_");
4313        let pascal_name = self.discriminator_to_variant_name(&combined);
4314
4315        // Add suffix if it doesn't already have one
4316        if !pascal_name.ends_with("Content")
4317            && !pascal_name.ends_with("Block")
4318            && !pascal_name.ends_with("Type")
4319        {
4320            Some(format!("{pascal_name}Content"))
4321        } else {
4322            Some(pascal_name)
4323        }
4324    }
4325
4326    fn extract_type_name_from_properties(
4327        &self,
4328        properties: &std::collections::BTreeMap<String, crate::openapi::Schema>,
4329    ) -> Option<String> {
4330        // Get property names, excluding common structural properties
4331        let significant_props: Vec<&String> = properties
4332            .keys()
4333            .filter(|name| !["type", "id", "cache_control"].contains(&name.as_str()))
4334            .collect();
4335
4336        if significant_props.is_empty() {
4337            return None;
4338        }
4339
4340        // Strategy 1: If there's only one significant property, use it
4341        if significant_props.len() == 1 {
4342            let prop_name = significant_props[0];
4343            return Some(self.discriminator_to_variant_name(prop_name));
4344        }
4345
4346        // Strategy 2: Use the first property alphabetically for consistency
4347        // This provides deterministic naming without hardcoded preferences
4348        let mut sorted_props = significant_props.clone();
4349        sorted_props.sort();
4350        if let Some(first_prop) = sorted_props.first() {
4351            return Some(self.discriminator_to_variant_name(first_prop));
4352        }
4353
4354        None
4355    }
4356
4357    fn openapi_type_to_rust_type(
4358        &self,
4359        openapi_type: OpenApiSchemaType,
4360        details: &crate::openapi::SchemaDetails,
4361    ) -> String {
4362        // Q2.0: route through the TypeMapper chokepoint. With the default
4363        // config this produces bit-identical output to the pre-refactor
4364        // match; later Q2.* issues add format-aware branches inside
4365        // TypeMapper without touching this function.
4366        self.type_mapper.map(openapi_type, details).rust_type
4367    }
4368
4369    #[allow(dead_code)]
4370    fn fallback_discriminator_value(&self, schema_name: &str) -> String {
4371        self.fallback_discriminator_value_for_field(schema_name, "type")
4372    }
4373
4374    fn fallback_discriminator_value_for_field(
4375        &self,
4376        schema_name: &str,
4377        field_name: &str,
4378    ) -> String {
4379        // Try to extract from referenced schema first
4380        if let Some(ref_schema) = self.schemas.get(schema_name) {
4381            if let Some(extracted) =
4382                self.extract_discriminator_value_for_field(ref_schema, field_name)
4383            {
4384                return extracted;
4385            }
4386        }
4387
4388        // Fall back to generating from name
4389        self.generate_discriminator_value_from_name(schema_name)
4390    }
4391
4392    fn generate_discriminator_value_from_name(&self, schema_name: &str) -> String {
4393        // Convert schema names like "ResponseCreatedEvent" to "response.created"
4394        let mut result = String::new();
4395        let mut chars = schema_name.chars().peekable();
4396        let mut first = true;
4397
4398        while let Some(c) = chars.next() {
4399            if c.is_uppercase()
4400                && !first
4401                && chars
4402                    .peek()
4403                    .map(|&next| next.is_lowercase())
4404                    .unwrap_or(false)
4405            {
4406                result.push('.');
4407            }
4408            result.push(c.to_ascii_lowercase());
4409            first = false;
4410        }
4411
4412        // Remove common suffixes
4413        if result.ends_with("event") {
4414            result = result[..result.len() - 5].to_string();
4415        }
4416
4417        // Add "response." prefix if it looks like a response event
4418        if schema_name.starts_with("Response") && !result.starts_with("response.") {
4419            result = format!("response.{}", result.trim_start_matches("response"));
4420        }
4421
4422        result
4423    }
4424
4425    fn to_rust_variant_name(&self, schema_name: &str) -> String {
4426        // Convert "ResponseCreatedEvent" to "Created", "UserStatus" to "UserStatus", etc.
4427        let mut name = schema_name;
4428
4429        // Remove common prefixes for cleaner variant names
4430        if name.starts_with("Response") && name.len() > 8 {
4431            name = &name[8..]; // Remove "Response"
4432        }
4433
4434        // Remove common suffixes
4435        if name.ends_with("Event") && name.len() > 5 {
4436            name = &name[..name.len() - 5]; // Remove "Event"
4437        }
4438
4439        // Trim leading and trailing underscores
4440        name = name.trim_matches('_');
4441
4442        // Convert underscores to camel case using our existing function
4443        if name.is_empty() {
4444            schema_name.to_string()
4445        } else {
4446            // Use discriminator_to_variant_name to properly handle underscores
4447            self.discriminator_to_variant_name(name)
4448        }
4449    }
4450
4451    /// Register an inline string enum as a named `StringEnum` schema and
4452    /// return a `Reference` to it. Shared by property-level enums
4453    /// (`{Schema}{Prop}`) and array-item enums (`{Schema}{Prop}Item`).
4454    ///
4455    /// Resolves a name that either matches an existing same-valued
4456    /// enum (dedup) or doesn't collide with a different one.
4457    ///
4458    /// Two distinct inline enums can land on the same primary
4459    /// candidate when a parent schema has a property like
4460    /// `type` that recurs at multiple nesting levels — e.g.
4461    /// Latitude.sh's `plan_data.type = ["plans"]` (the
4462    /// JSON-API resource type) and
4463    /// `plan_data.attributes.specs.drives[].type =
4464    /// ["SSD","HDD","NVME"]` both want to become
4465    /// `PlanDataType`. We must NOT silently overwrite the
4466    /// first registration: that breaks deserialization
4467    /// because both fields end up referencing whichever
4468    /// enum was processed last.
4469    ///
4470    /// Disambiguation strategy: append the PascalCase first
4471    /// enum value (`PlanDataTypeNVME` vs `PlanDataTypePlans`)
4472    /// and, if that's also claimed with different values,
4473    /// fall back to a numeric `_2`, `_3`, … suffix.
4474    fn hoist_inline_string_enum(
4475        &mut self,
4476        schema: &Schema,
4477        enum_values: Vec<String>,
4478        primary_name: String,
4479        dependencies: &mut HashSet<String>,
4480    ) -> SchemaType {
4481        fn matches_values(existing: &AnalyzedSchema, values: &[String]) -> bool {
4482            matches!(
4483                &existing.schema_type,
4484                SchemaType::StringEnum { values: existing_values }
4485                    if existing_values == values
4486            )
4487        }
4488
4489        let mut enum_type_name = primary_name.clone();
4490        let should_insert = match self.resolved_cache.get(&enum_type_name) {
4491            None => true,
4492            Some(existing) if matches_values(existing, &enum_values) => false,
4493            Some(_) => {
4494                // Collision with different values — try a
4495                // value-suffixed name first.
4496                let suffix = enum_values
4497                    .first()
4498                    .map(|v| self.to_pascal_case(v))
4499                    .unwrap_or_else(|| "Variant".to_string());
4500                let candidate = format!("{primary_name}{suffix}");
4501
4502                let resolved = match self.resolved_cache.get(&candidate) {
4503                    None => Some((candidate.clone(), true)),
4504                    Some(existing) if matches_values(existing, &enum_values) => {
4505                        Some((candidate.clone(), false))
4506                    }
4507                    Some(_) => {
4508                        // Walk a numeric suffix until we find
4509                        // a slot that's free or matches.
4510                        let mut found = None;
4511                        for n in 2..1000 {
4512                            let numbered = format!("{candidate}_{n}");
4513                            match self.resolved_cache.get(&numbered) {
4514                                None => {
4515                                    found = Some((numbered, true));
4516                                    break;
4517                                }
4518                                Some(existing) if matches_values(existing, &enum_values) => {
4519                                    found = Some((numbered, false));
4520                                    break;
4521                                }
4522                                Some(_) => continue,
4523                            }
4524                        }
4525                        found
4526                    }
4527                };
4528
4529                let (resolved_name, insert) = resolved.unwrap_or((candidate, true));
4530                enum_type_name = resolved_name;
4531                insert
4532            }
4533        };
4534
4535        // Store the enum as a named schema if this is the
4536        // first time we've seen this exact (name, values) pair.
4537        if should_insert {
4538            self.resolved_cache.insert(
4539                enum_type_name.clone(),
4540                AnalyzedSchema {
4541                    name: enum_type_name.clone(),
4542                    original: serde_json::to_value(schema).unwrap_or(Value::Null),
4543                    schema_type: SchemaType::StringEnum {
4544                        values: enum_values,
4545                    },
4546                    dependencies: HashSet::new(),
4547                    nullable: false,
4548                    description: schema.details().description.clone(),
4549                    default: schema.details().default.clone(),
4550                },
4551            );
4552        }
4553
4554        // Return a reference to the named enum type
4555        dependencies.insert(enum_type_name.clone());
4556        SchemaType::Reference {
4557            target: enum_type_name,
4558        }
4559    }
4560
4561    fn analyze_array_schema(
4562        &mut self,
4563        schema: &Schema,
4564        parent_schema_name: &str,
4565        dependencies: &mut HashSet<String>,
4566    ) -> Result<SchemaType> {
4567        let details = schema.details();
4568
4569        // Positional schemas first: when the spec pins the length, the array is
4570        // a tuple, and `items` (if present at all) only describes elements that
4571        // cannot occur.
4572        if let Some(positions) = details.positional_items() {
4573            return self.analyze_positional_items(
4574                positions,
4575                details,
4576                parent_schema_name,
4577                dependencies,
4578            );
4579        }
4580
4581        // Check if items field is present
4582        if let Some(items_schema) = details.item_schema() {
4583            let item_type = self.analyze_item_schema(
4584                items_schema,
4585                parent_schema_name,
4586                &format!("{parent_schema_name}Item"),
4587                dependencies,
4588            )?;
4589            let item_type = self.hoist_inline_property_type(
4590                parent_schema_name,
4591                "Item",
4592                item_type,
4593                dependencies,
4594            );
4595            Ok(SchemaType::Array {
4596                item_type: Box::new(item_type),
4597            })
4598        } else {
4599            // No items specified, fall back to generic array
4600            Ok(
4601                self.untyped_value_array(
4602                    self.untyped_context(""),
4603                    UntypedReason::ArrayWithoutItems,
4604                ),
4605            )
4606        }
4607    }
4608
4609    /// The single Rust type every branch of a union maps to, if there is one.
4610    ///
4611    /// Specs routinely spell one type as several branches that differ only in
4612    /// constraints — Runway declares a URI field as three `string` branches
4613    /// with different `pattern`s and lengths. Every value that matches any
4614    /// branch is still a `String`, so the union has an exact Rust type; only
4615    /// the constraints, which are documentation here, differ. Branches whose
4616    /// mapped types disagree (a `uri` alongside a plain string) are left alone.
4617    fn shared_branch_type(&self, branches: &[Schema]) -> Option<SchemaType> {
4618        let mut mapped: Option<(String, Option<String>)> = None;
4619        let mut scalar_kind: Option<OpenApiSchemaType> = None;
4620        let mut formats_agree = true;
4621        for branch in branches {
4622            if branch.reference().is_some() {
4623                return None;
4624            }
4625            let details = branch.details();
4626            if details.enum_values.is_some()
4627                || details.const_value.is_some()
4628                || details.properties.is_some()
4629            {
4630                return None;
4631            }
4632            let scalar = match branch.schema_type()? {
4633                scalar @ (OpenApiSchemaType::String
4634                | OpenApiSchemaType::Integer
4635                | OpenApiSchemaType::Number
4636                | OpenApiSchemaType::Boolean) => scalar.clone(),
4637                _ => return None,
4638            };
4639            match &scalar_kind {
4640                Some(existing) if *existing != scalar => return None,
4641                Some(_) => {}
4642                None => scalar_kind = Some(scalar.clone()),
4643            }
4644
4645            let candidate = self.type_mapper.map(scalar, details);
4646            let candidate = (candidate.rust_type, candidate.serde_with);
4647            match &mapped {
4648                Some(existing) if *existing != candidate => formats_agree = false,
4649                Some(_) => {}
4650                None => mapped = Some(candidate),
4651            }
4652        }
4653
4654        if formats_agree {
4655            return mapped.map(|(rust_type, serde_with)| SchemaType::Primitive {
4656                rust_type,
4657                serde_with,
4658            });
4659        }
4660
4661        // Same wire type, different typed-scalar refinements — gcore declares an
4662        // IP field as `ipv4 | ipv6 | ipv4network | ipv6network`, which map to
4663        // three different Rust types. No single refinement holds for every
4664        // value, but the declared type does, so fall back to it rather than to
4665        // `serde_json::Value`.
4666        let scalar = scalar_kind?;
4667        let mapped = self
4668            .type_mapper
4669            .map(scalar, &crate::openapi::SchemaDetails::default());
4670        Some(SchemaType::Primitive {
4671            rust_type: mapped.rust_type,
4672            serde_with: mapped.serde_with,
4673        })
4674    }
4675
4676    /// Replace union branches that are local JSON Pointers with the schemas
4677    /// they name.
4678    ///
4679    /// PagerDuty builds a request body from three pointers into a response's
4680    /// `oneOf`. Each branch is resolvable, but a union whose branches are
4681    /// unresolvable references has nothing to build variants from, so the whole
4682    /// union used to degrade to `serde_json::Value`. Expanding is one level
4683    /// deep, which is all these shapes need.
4684    fn expand_pointer_branches(&self, branches: &[Schema]) -> Option<Vec<Schema>> {
4685        let mut expanded = Vec::with_capacity(branches.len());
4686        let mut changed = false;
4687        for branch in branches {
4688            let resolved = branch
4689                .reference()
4690                .filter(|reference| self.extract_schema_name(reference).is_none())
4691                .and_then(|reference| reference.strip_prefix('#'))
4692                .filter(|pointer| pointer.starts_with('/'))
4693                .and_then(|pointer| self.openapi_spec.pointer(pointer))
4694                .and_then(|value| Schema::deserialize(value).ok())
4695                .filter(|schema| schema.reference().is_none());
4696            match resolved {
4697                Some(schema) => {
4698                    expanded.push(schema);
4699                    changed = true;
4700                }
4701                None => expanded.push(branch.clone()),
4702            }
4703        }
4704        changed.then_some(expanded)
4705    }
4706
4707    /// Analyze a schema that declares its own `properties` *and* a union.
4708    ///
4709    /// `{properties: {...}, anyOf: [A, B]}` means "these fields, and one of
4710    /// these shapes" — Cloudflare's DLP entries and OpenAI's `file_search`
4711    /// resources are written this way. Neither half can be dropped: reading
4712    /// only the union loses the declared fields, and reading only the object
4713    /// loses the alternatives, which is why this used to generate
4714    /// `serde_json::Value`.
4715    ///
4716    /// The object is generated as a struct and the union as its own enum, held
4717    /// in a `#[serde(flatten)]` field. Returns `None` when the schema has no
4718    /// properties of its own, leaving plain unions to the union analyzers.
4719    fn analyze_object_with_variants(
4720        &mut self,
4721        schema: &Schema,
4722        branches: &[Schema],
4723        schema_name: &str,
4724        dependencies: &mut HashSet<String>,
4725    ) -> Result<Option<SchemaType>> {
4726        let details = schema.details();
4727        if details.properties.as_ref().is_none_or(BTreeMap::is_empty) || branches.is_empty() {
4728            return Ok(None);
4729        }
4730        // A nullable wrapper is not a variant set, and requiredness-only
4731        // branches are handled before this.
4732        if schema.is_nullable_pattern() || Self::union_only_constrains_requiredness(branches) {
4733            return Ok(None);
4734        }
4735
4736        let base = self.analyze_object_schema(schema, dependencies)?;
4737        let SchemaType::Object {
4738            properties,
4739            required,
4740            additional_properties,
4741            ..
4742        } = base
4743        else {
4744            return Ok(None);
4745        };
4746
4747        let variant_name = self.unique_hoisted_name(schema_name, "Variant");
4748        let variant_type = self.analyze_anyof_union(
4749            branches,
4750            schema.discriminator(),
4751            dependencies,
4752            &variant_name,
4753        )?;
4754        // If the union itself has no representation, keep the object rather
4755        // than flattening something untyped into it.
4756        if matches!(variant_type, SchemaType::Untyped { .. }) {
4757            return Ok(Some(SchemaType::Object {
4758                properties,
4759                required,
4760                additional_properties,
4761                variant: None,
4762            }));
4763        }
4764
4765        self.resolved_cache.insert(
4766            variant_name.clone(),
4767            AnalyzedSchema {
4768                name: variant_name.clone(),
4769                original: Value::Null,
4770                dependencies: schema_type_dependencies(&variant_type),
4771                schema_type: variant_type,
4772                nullable: false,
4773                description: None,
4774                default: None,
4775            },
4776        );
4777        dependencies.insert(variant_name.clone());
4778
4779        Ok(Some(SchemaType::Object {
4780            properties,
4781            required,
4782            additional_properties,
4783            variant: Some(SchemaRef {
4784                target: variant_name,
4785                nullable: false,
4786            }),
4787        }))
4788    }
4789
4790    /// Whether a union's branches constrain only which properties are
4791    /// required.
4792    ///
4793    /// Cloudflare writes `{properties: {...}, anyOf: [{required: [commit_hash]},
4794    /// {required: [branch]}]}` to say "one of these two fields must be present".
4795    /// The branches carry no type of their own, and Rust has no way to express
4796    /// the alternation, so the schema is the object its properties describe —
4797    /// with both fields optional — rather than an unrepresentable union.
4798    fn union_only_constrains_requiredness(branches: &[Schema]) -> bool {
4799        !branches.is_empty()
4800            && branches.iter().all(|branch| {
4801                let details = branch.details();
4802                branch.schema_type().is_none()
4803                    && branch.reference().is_none()
4804                    && branch.union_variants().is_none()
4805                    && details.properties.is_none()
4806                    && details.enum_values.is_none()
4807                    && details.const_value.is_none()
4808                    && details.items.is_none()
4809                    && details.additional_properties.is_none()
4810                    && details.required.is_some()
4811            })
4812    }
4813
4814    /// Analyze a union whose branch list is empty.
4815    ///
4816    /// `oneOf: []` and `anyOf: []` admit every value, so the schema means
4817    /// whatever its remaining keywords say. Discord ships
4818    /// `{type: integer, format: int32, oneOf: []}` for several enums; reading
4819    /// only the empty union throws away a perfectly good `i32`.
4820    fn analyze_empty_union(
4821        &mut self,
4822        schema: &Schema,
4823        dependencies: &mut HashSet<String>,
4824    ) -> Result<SchemaType> {
4825        // A union that declares no type of its own is still an object when it
4826        // carries properties: the branches sit alongside them, not instead of
4827        // them.
4828        let Some(declared) = schema
4829            .declared_type()
4830            .cloned()
4831            .or_else(|| schema.inferred_type())
4832            .or_else(|| {
4833                schema
4834                    .details()
4835                    .properties
4836                    .is_some()
4837                    .then_some(OpenApiSchemaType::Object)
4838            })
4839        else {
4840            return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema));
4841        };
4842        match declared {
4843            OpenApiSchemaType::Object => self.analyze_object_schema(schema, dependencies),
4844            OpenApiSchemaType::Array => {
4845                let context = self
4846                    .current_schema_name
4847                    .clone()
4848                    .unwrap_or_else(|| "Inline".to_string());
4849                self.analyze_array_schema(schema, &context, dependencies)
4850            }
4851            scalar => {
4852                let mapped = self.type_mapper.map(scalar, schema.details());
4853                Ok(SchemaType::Primitive {
4854                    rust_type: mapped.rust_type,
4855                    serde_with: mapped.serde_with,
4856                })
4857            }
4858        }
4859    }
4860
4861    /// Resolve a local `$ref` that points somewhere other than
4862    /// `#/components/schemas/<name>`.
4863    ///
4864    /// A JSON Pointer may address any node in the document, and real specs use
4865    /// that: PagerDuty references a parameter's schema
4866    /// (`#/components/parameters/audit_method_type/schema`) and a single member
4867    /// of another schema's composition (`#/components/schemas/Tag/allOf/0`).
4868    /// Resolving only the component-schema form left those fields untyped even
4869    /// though the target is right there in the document.
4870    ///
4871    /// The target is analyzed as an inline schema and named after its pointer,
4872    /// so two references to the same node share one generated type.
4873    fn resolve_pointer_schema(
4874        &mut self,
4875        reference: &str,
4876        dependencies: &mut HashSet<String>,
4877    ) -> Result<Option<SchemaType>> {
4878        let Some(pointer) = reference.strip_prefix('#') else {
4879            return Ok(None);
4880        };
4881        if pointer.is_empty() || !pointer.starts_with('/') {
4882            return Ok(None);
4883        }
4884        let name = pointer_type_name(pointer);
4885        if name.is_empty() {
4886            return Ok(None);
4887        }
4888        // Already resolved once, or currently being resolved further up the
4889        // stack: reference the name rather than expanding it again.
4890        if self.resolved_cache.contains_key(&name) || !self.resolving_pointers.insert(name.clone())
4891        {
4892            dependencies.insert(name.clone());
4893            return Ok(Some(SchemaType::Reference { target: name }));
4894        }
4895
4896        let resolved = (|| {
4897            let value = self.openapi_spec.pointer(pointer)?.clone();
4898            Schema::deserialize(&value).ok()
4899        })();
4900        let Some(schema) = resolved else {
4901            self.resolving_pointers.remove(&name);
4902            return Ok(None);
4903        };
4904
4905        let analyzed = self.analyze_property_schema_with_context(&schema, None, dependencies);
4906        self.resolving_pointers.remove(&name);
4907        let analyzed = analyzed?;
4908        Ok(Some(self.hoist_inline_property_type(
4909            &name,
4910            "",
4911            analyzed,
4912            dependencies,
4913        )))
4914    }
4915
4916    /// Give a property type a name when it needs one.
4917    ///
4918    /// A struct field can hold a primitive, a reference, an array, or a tuple.
4919    /// Anything else — a merged `allOf`, an inline object, a union, an inline
4920    /// enum — has to be generated as its own item, and a field can only reach
4921    /// it by reference. Analysis used to leave those in place, and the
4922    /// generator, with nothing it could write, emitted `serde_json::Value`:
4923    /// the schema was understood and then thrown away at the last step.
4924    fn hoist_inline_property_type(
4925        &mut self,
4926        schema_name: &str,
4927        property_name: &str,
4928        schema_type: SchemaType,
4929        dependencies: &mut HashSet<String>,
4930    ) -> SchemaType {
4931        if schema_type.renders_inline() {
4932            return schema_type;
4933        }
4934
4935        let hoisted_name = self.unique_hoisted_name(schema_name, property_name);
4936        let hoisted_dependencies = schema_type_dependencies(&schema_type);
4937        self.resolved_cache.insert(
4938            hoisted_name.clone(),
4939            AnalyzedSchema {
4940                name: hoisted_name.clone(),
4941                original: Value::Null,
4942                schema_type,
4943                dependencies: hoisted_dependencies,
4944                nullable: false,
4945                description: None,
4946                default: None,
4947            },
4948        );
4949        dependencies.insert(hoisted_name.clone());
4950        SchemaType::Reference {
4951            target: hoisted_name,
4952        }
4953    }
4954
4955    /// A generated name for a hoisted property type that no other schema has
4956    /// claimed. Collisions are resolved by suffix rather than by overwriting,
4957    /// which would silently retype an unrelated schema.
4958    fn unique_hoisted_name(&self, schema_name: &str, property_name: &str) -> String {
4959        use heck::ToPascalCase;
4960
4961        let base = format!("{schema_name}{}", property_name.to_pascal_case());
4962        if !self.schemas.contains_key(&base) && !self.resolved_cache.contains_key(&base) {
4963            return base;
4964        }
4965        let mut suffix = 2;
4966        loop {
4967            let candidate = format!("{base}{suffix}");
4968            if !self.schemas.contains_key(&candidate)
4969                && !self.resolved_cache.contains_key(&candidate)
4970            {
4971                return candidate;
4972            }
4973            suffix += 1;
4974        }
4975    }
4976
4977    /// Analyze positional element schemas — 2020-12 `prefixItems` or the
4978    /// draft-04 `items: [A, B]` tuple form — into the tightest type the spec
4979    /// justifies.
4980    ///
4981    /// Three tiers, because `prefixItems` alone does not bound an array's
4982    /// length and a Rust tuple is fixed-arity:
4983    ///
4984    /// 1. the length is pinned → a tuple, one element per position;
4985    /// 2. no extras are allowed and every position is the same schema →
4986    ///    `Vec<T>`, which accepts any permitted length;
4987    /// 3. otherwise → `Vec<serde_json::Value>`, since a payload may legally
4988    ///    carry more elements, of other types, than the positions describe.
4989    fn analyze_positional_items(
4990        &mut self,
4991        positions: &[Schema],
4992        details: &crate::openapi::SchemaDetails,
4993        parent_schema_name: &str,
4994        dependencies: &mut HashSet<String>,
4995    ) -> Result<SchemaType> {
4996        if details.positional_items_are_exact() && !positions.is_empty() {
4997            let mut element_types = Vec::with_capacity(positions.len());
4998            for (index, position) in positions.iter().enumerate() {
4999                let element_type = self.analyze_item_schema(
5000                    position,
5001                    parent_schema_name,
5002                    &format!("{parent_schema_name}Item{}", index + 1),
5003                    dependencies,
5004                )?;
5005                element_types.push(self.hoist_inline_property_type(
5006                    parent_schema_name,
5007                    &format!("Item{}", index + 1),
5008                    element_type,
5009                    dependencies,
5010                ));
5011            }
5012            return Ok(SchemaType::Tuple { element_types });
5013        }
5014
5015        // Analyze a shared position only once, and only when the positions are
5016        // interchangeable: analyzing every position would hoist a named type
5017        // per inline object, and tiers 2 and 3 discard all but one of them.
5018        if details.positional_items_are_closed()
5019            && let Some(shared) = shared_positional_schema(positions)
5020        {
5021            let item_type = self.analyze_item_schema(
5022                shared,
5023                parent_schema_name,
5024                &format!("{parent_schema_name}Item"),
5025                dependencies,
5026            )?;
5027            return Ok(SchemaType::Array {
5028                item_type: Box::new(item_type),
5029            });
5030        }
5031
5032        Ok(self.untyped_value_array(self.untyped_context(""), UntypedReason::OpenPositionalItems))
5033    }
5034
5035    /// Analyze one element schema into its generated type.
5036    ///
5037    /// `inline_name` names whatever has to be hoisted out of an inline element
5038    /// schema — an object, a string enum, a union — so tuple positions can pass
5039    /// a per-position name. `parent_schema_name` stays the enclosing schema,
5040    /// which is what a `$recursiveRef: "#"` element resolves to.
5041    fn analyze_item_schema(
5042        &mut self,
5043        items_schema: &Schema,
5044        parent_schema_name: &str,
5045        inline_name: &str,
5046        dependencies: &mut HashSet<String>,
5047    ) -> Result<SchemaType> {
5048        let item_type = match items_schema {
5049            Schema::Reference { reference, .. } => {
5050                // Array of referenced types
5051                let target = self
5052                    .extract_schema_name(reference)
5053                    .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))?
5054                    .to_string();
5055                dependencies.insert(target.clone());
5056                SchemaType::Reference { target }
5057            }
5058            Schema::RecursiveRef { recursive_ref, .. } => {
5059                // Array of recursive references
5060                if recursive_ref == "#" {
5061                    // Self-reference to the current schema
5062                    let target = self
5063                        .find_recursive_anchor_schema()
5064                        .unwrap_or_else(|| parent_schema_name.to_string());
5065                    dependencies.insert(target.clone());
5066                    SchemaType::Reference { target }
5067                } else {
5068                    let target = self
5069                        .extract_schema_name(recursive_ref)
5070                        .unwrap_or("RecursiveType")
5071                        .to_string();
5072                    dependencies.insert(target.clone());
5073                    SchemaType::Reference { target }
5074                }
5075            }
5076            Schema::Typed { schema_type, .. } => {
5077                // Array of primitive types
5078                match schema_type {
5079                    OpenApiSchemaType::String => {
5080                        // Inline string enum in array items — hoist to a
5081                        // named enum (`{Parent}Item`) instead of collapsing
5082                        // to `Vec<String>`.
5083                        match items_schema
5084                            .details()
5085                            .string_enum_values()
5086                            .filter(|values| !values.is_empty())
5087                        {
5088                            Some(values) => self.hoist_inline_string_enum(
5089                                items_schema,
5090                                values,
5091                                inline_name.to_string(),
5092                                dependencies,
5093                            ),
5094                            None => SchemaType::Primitive {
5095                                rust_type: "String".to_string(),
5096                                serde_with: None,
5097                            },
5098                        }
5099                    }
5100                    OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
5101                        let details = items_schema.details();
5102                        let rust_type = self.get_number_rust_type(schema_type.clone(), details);
5103                        SchemaType::Primitive {
5104                            rust_type,
5105                            serde_with: None,
5106                        }
5107                    }
5108                    OpenApiSchemaType::Boolean => SchemaType::Primitive {
5109                        rust_type: "bool".to_string(),
5110                        serde_with: None,
5111                    },
5112                    OpenApiSchemaType::Object => {
5113                        // Inline object in array - create a named schema for it
5114                        let object_type_name = inline_name.to_string();
5115
5116                        // Analyze the object schema
5117                        let object_type = self.analyze_object_schema(items_schema, dependencies)?;
5118
5119                        // Create an analyzed schema for the inline object
5120                        let inline_schema = AnalyzedSchema {
5121                            name: object_type_name.clone(),
5122                            original: serde_json::to_value(items_schema).unwrap_or(Value::Null),
5123                            schema_type: object_type,
5124                            dependencies: dependencies.clone(),
5125                            nullable: false,
5126                            description: items_schema.details().description.clone(),
5127                            default: None,
5128                        };
5129
5130                        // Add the inline object as a named schema
5131                        self.resolved_cache
5132                            .insert(object_type_name.clone(), inline_schema);
5133                        dependencies.insert(object_type_name.clone());
5134
5135                        // Return a reference to the named schema
5136                        SchemaType::Reference {
5137                            target: object_type_name,
5138                        }
5139                    }
5140                    OpenApiSchemaType::Array => {
5141                        // Array of arrays - recursively analyze
5142                        self.analyze_array_schema(items_schema, parent_schema_name, dependencies)?
5143                    }
5144                    _ => self.untyped_value(
5145                        self.untyped_context(""),
5146                        UntypedReason::UnsupportedTypeKeyword,
5147                    ),
5148                }
5149            }
5150            Schema::OneOf { .. } | Schema::AnyOf { .. } => {
5151                // Union types in arrays - analyze recursively
5152                let analyzed = self.analyze_schema_value(items_schema, "ArrayItem")?;
5153
5154                // If we got a discriminated union or union, we need to create a separate schema for it
5155                match &analyzed.schema_type {
5156                    SchemaType::DiscriminatedUnion { .. } | SchemaType::Union { .. } => {
5157                        // Generate a unique name for the union schema based on the parent context
5158                        // Use the parent context directly to maintain consistent naming
5159                        let union_name = format!("{inline_name}Union");
5160
5161                        // Create a new analyzed schema with the correct name
5162                        let mut union_schema = analyzed;
5163                        union_schema.name = union_name.clone();
5164
5165                        // Add the union as a separate schema
5166                        self.resolved_cache.insert(union_name.clone(), union_schema);
5167
5168                        // Add dependency
5169                        dependencies.insert(union_name.clone());
5170
5171                        // Return a reference to the union schema
5172                        SchemaType::Reference { target: union_name }
5173                    }
5174                    _ => analyzed.schema_type,
5175                }
5176            }
5177            Schema::Untyped { .. } => {
5178                // Try to infer the type
5179                if let Some(inferred) = items_schema.inferred_type() {
5180                    match inferred {
5181                        OpenApiSchemaType::Object => {
5182                            // Inline object in array - create a named schema for it
5183                            let object_type_name = inline_name.to_string();
5184
5185                            // Analyze the object schema
5186                            let object_type =
5187                                self.analyze_object_schema(items_schema, dependencies)?;
5188
5189                            // Create an analyzed schema for the inline object
5190                            let inline_schema = AnalyzedSchema {
5191                                name: object_type_name.clone(),
5192                                original: serde_json::to_value(items_schema).unwrap_or(Value::Null),
5193                                schema_type: object_type,
5194                                dependencies: dependencies.clone(),
5195                                nullable: false,
5196                                description: items_schema.details().description.clone(),
5197                                default: None,
5198                            };
5199
5200                            // Add the inline object as a named schema
5201                            self.resolved_cache
5202                                .insert(object_type_name.clone(), inline_schema);
5203                            dependencies.insert(object_type_name.clone());
5204
5205                            // Return a reference to the named schema
5206                            SchemaType::Reference {
5207                                target: object_type_name,
5208                            }
5209                        }
5210                        OpenApiSchemaType::String => {
5211                            // Typeless (OpenAPI 3.1) enum in array items —
5212                            // same hoisting as the typed-string arm.
5213                            match items_schema
5214                                .details()
5215                                .string_enum_values()
5216                                .filter(|values| !values.is_empty())
5217                            {
5218                                Some(values) => self.hoist_inline_string_enum(
5219                                    items_schema,
5220                                    values,
5221                                    inline_name.to_string(),
5222                                    dependencies,
5223                                ),
5224                                None => SchemaType::Primitive {
5225                                    rust_type: "String".to_string(),
5226                                    serde_with: None,
5227                                },
5228                            }
5229                        }
5230                        OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
5231                            let details = items_schema.details();
5232                            let rust_type = self.get_number_rust_type(inferred, details);
5233                            SchemaType::Primitive {
5234                                rust_type,
5235                                serde_with: None,
5236                            }
5237                        }
5238                        OpenApiSchemaType::Boolean => SchemaType::Primitive {
5239                            rust_type: "bool".to_string(),
5240                            serde_with: None,
5241                        },
5242                        // `type: null` admits exactly one value; Rust spells
5243                        // that `()`, which serde reads from and writes as null.
5244                        OpenApiSchemaType::Null => SchemaType::Primitive {
5245                            rust_type: self.type_mapper.null_unit().rust_type,
5246                            serde_with: None,
5247                        },
5248                        _ => self.untyped_value(
5249                            self.untyped_context(""),
5250                            UntypedReason::UnsupportedTypeKeyword,
5251                        ),
5252                    }
5253                } else {
5254                    self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema)
5255                }
5256            }
5257            // Compositions and anything else this match does not special-case
5258            // go through the general property analyzer, which understands
5259            // `allOf` merging. The caller hoists whatever comes back if a field
5260            // cannot hold it directly.
5261            _ => self.analyze_property_schema_with_context(items_schema, None, dependencies)?,
5262        };
5263
5264        Ok(item_type)
5265    }
5266
5267    fn get_number_rust_type(
5268        &self,
5269        schema_type: OpenApiSchemaType,
5270        details: &crate::openapi::SchemaDetails,
5271    ) -> String {
5272        // Q2.0: delegate to the TypeMapper chokepoint. The fallback for
5273        // non-numeric inputs is preserved for backwards compatibility
5274        // (callers in 2025-era code path `Integer | Number` here).
5275        let format = details.format.as_deref();
5276        match schema_type {
5277            OpenApiSchemaType::Integer => self.type_mapper.integer_format(format).rust_type,
5278            OpenApiSchemaType::Number => self.type_mapper.number_format(format).rust_type,
5279            _ => self.type_mapper.dynamic_json().rust_type,
5280        }
5281    }
5282
5283    fn analyze_anyof_union(
5284        &mut self,
5285        any_of_schemas: &[Schema],
5286        discriminator: Option<&Discriminator>,
5287        dependencies: &mut HashSet<String>,
5288        context_name: &str,
5289    ) -> Result<SchemaType> {
5290        // Branches may be pointers into other parts of the document.
5291        let expanded_branches;
5292        let any_of_schemas = match self.expand_pointer_branches(any_of_schemas) {
5293            Some(expanded) => {
5294                expanded_branches = expanded;
5295                expanded_branches.as_slice()
5296            }
5297            None => any_of_schemas,
5298        };
5299
5300        // Drop {"type": "null"} variants. Nullability is surfaced as Option<T>
5301        // at the property level via is_nullable_pattern(); leaving the null
5302        // variant in here would produce a phantom `()` or `serde_json::Value`
5303        // type alias that the generator can't render.
5304        let filtered_owned: Vec<Schema>;
5305        let any_of_schemas: &[Schema] = if any_of_schemas
5306            .iter()
5307            .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
5308        {
5309            filtered_owned = any_of_schemas
5310                .iter()
5311                .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
5312                .cloned()
5313                .collect();
5314            if filtered_owned.is_empty() {
5315                return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema));
5316            }
5317            if filtered_owned.len() == 1 {
5318                return self
5319                    .analyze_schema_value(&filtered_owned[0], context_name)
5320                    .map(|a| a.schema_type);
5321            }
5322            &filtered_owned
5323        } else {
5324            any_of_schemas
5325        };
5326
5327        // A union of one is that one: gcore writes `anyOf: [{allOf: [...]}]`
5328        // to attach an example to a referenced error schema.
5329        if let [only] = any_of_schemas {
5330            return self
5331                .analyze_schema_value(only, context_name)
5332                .map(|analyzed| analyzed.schema_type);
5333        }
5334
5335        // Branches that differ only in constraints share one Rust type, so
5336        // there is no union to build. Checked before the shape patterns below,
5337        // which would otherwise synthesize a union type and give up on it.
5338        if let Some(shared) = self.shared_branch_type(any_of_schemas) {
5339            return Ok(shared);
5340        }
5341
5342        // Pattern 2: Multiple complex types or mixed primitive/complex = flexible union
5343        let has_refs = any_of_schemas.iter().any(|s| s.is_reference());
5344        let has_objects = any_of_schemas.iter().any(|s| {
5345            matches!(s.schema_type(), Some(OpenApiSchemaType::Object))
5346                || s.inferred_type() == Some(OpenApiSchemaType::Object)
5347        });
5348        let has_arrays = any_of_schemas
5349            .iter()
5350            .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Array)));
5351
5352        // Handle mixed primitive and complex types (like string + array of objects)
5353        // Skip this pattern if all schemas are strings or const values (handle in pattern 3)
5354        let all_string_like = any_of_schemas.iter().all(|s| {
5355            matches!(s.schema_type(), Some(OpenApiSchemaType::String))
5356                || s.details().const_value.is_some()
5357        });
5358
5359        if (has_refs || has_objects || has_arrays || any_of_schemas.len() > 1) && !all_string_like {
5360            // Check if this is a discriminated union
5361            if let Some(disc) = discriminator {
5362                // This is a discriminated anyOf union, analyze it the same way as oneOf
5363                return self.analyze_oneof_union(
5364                    any_of_schemas,
5365                    Some(disc),
5366                    context_name,
5367                    dependencies,
5368                );
5369            }
5370
5371            // Auto-detect implicit discriminator from const fields across all variants
5372            if let Some(disc_field) = self.detect_discriminator_field(any_of_schemas) {
5373                return self.analyze_oneof_union(
5374                    any_of_schemas,
5375                    Some(&Discriminator {
5376                        property_name: disc_field,
5377                        mapping: None,
5378                        default_mapping: None,
5379                        extensions: crate::extensions::Extensions::default(),
5380                    }),
5381                    context_name,
5382                    dependencies,
5383                );
5384            }
5385
5386            // Create an untagged union for flexible matching
5387            let mut variants = Vec::new();
5388
5389            for schema in any_of_schemas {
5390                if let Some(ref_str) = schema.reference() {
5391                    if let Some(target) = self.extract_schema_name(ref_str) {
5392                        dependencies.insert(target.to_string());
5393                        variants.push(SchemaRef {
5394                            target: target.to_string(),
5395                            nullable: false,
5396                        });
5397                    }
5398                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object))
5399                    || schema.inferred_type() == Some(OpenApiSchemaType::Object)
5400                {
5401                    // Generate inline object type for anyOf union
5402                    let inline_index = variants.len();
5403                    let inline_type_name = self.generate_inline_type_name(schema, inline_index);
5404
5405                    // Store inline schema for later analysis and generation
5406                    self.add_inline_schema(&inline_type_name, schema, dependencies)?;
5407
5408                    variants.push(SchemaRef {
5409                        target: inline_type_name,
5410                        nullable: false,
5411                    });
5412                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Array)) {
5413                    // Handle array types in unions by creating a type alias
5414                    let array_type =
5415                        self.analyze_array_schema(schema, context_name, dependencies)?;
5416
5417                    // Create a unique name for this array type in the union
5418                    let array_type_name = if let Some(items_schema) = schema.details().item_schema()
5419                    {
5420                        if let Some(ref_str) = items_schema.reference() {
5421                            if let Some(item_type_name) = self.extract_schema_name(ref_str) {
5422                                dependencies.insert(item_type_name.to_string());
5423                                format!("{item_type_name}Array")
5424                            } else {
5425                                self.generate_context_aware_name(
5426                                    context_name,
5427                                    "Array",
5428                                    variants.len(),
5429                                    Some(schema),
5430                                )
5431                            }
5432                        } else {
5433                            self.generate_context_aware_name(
5434                                context_name,
5435                                "Array",
5436                                variants.len(),
5437                                Some(schema),
5438                            )
5439                        }
5440                    } else {
5441                        self.generate_context_aware_name(
5442                            context_name,
5443                            "Array",
5444                            variants.len(),
5445                            Some(schema),
5446                        )
5447                    };
5448
5449                    // Store the array as a type alias
5450                    self.resolved_cache.insert(
5451                        array_type_name.clone(),
5452                        AnalyzedSchema {
5453                            name: array_type_name.clone(),
5454                            original: serde_json::to_value(schema).unwrap_or(Value::Null),
5455                            schema_type: array_type,
5456                            dependencies: HashSet::new(),
5457                            nullable: false,
5458                            description: Some("Array variant in union".to_string()),
5459                            default: None,
5460                        },
5461                    );
5462
5463                    // Add array type as a dependency
5464                    dependencies.insert(array_type_name.clone());
5465
5466                    variants.push(SchemaRef {
5467                        target: array_type_name,
5468                        nullable: false,
5469                    });
5470                } else if let Some(schema_type) = schema.schema_type() {
5471                    // Q2.7: when `primitive_unions` is on (default),
5472                    // emit the Rust type directly as the variant
5473                    // target — matches `analyze_untagged_oneof_union`
5474                    // and produces a clean
5475                    //   #[serde(untagged)] pub enum Foo { String(String), Integer(i64) }
5476                    // Pre-Q2.7 / opt-out emits a type alias per
5477                    // primitive (`pub type FooString = String`) and
5478                    // references the alias in the variant — works
5479                    // but adds noise.
5480                    let primitive_unions = self
5481                        .type_mapper
5482                        .config_shape_primitive_unions()
5483                        .unwrap_or(true);
5484
5485                    if primitive_unions {
5486                        let mapped = self.type_mapper.map(schema_type.clone(), schema.details());
5487                        variants.push(SchemaRef {
5488                            target: mapped.rust_type,
5489                            nullable: false,
5490                        });
5491                    } else {
5492                        let inline_index = variants.len();
5493                        let inline_type_name = match schema_type {
5494                            OpenApiSchemaType::String => {
5495                                if inline_index == 0 {
5496                                    format!("{context_name}String")
5497                                } else {
5498                                    format!("{context_name}StringVariant{inline_index}")
5499                                }
5500                            }
5501                            OpenApiSchemaType::Number => {
5502                                if inline_index == 0 {
5503                                    format!("{context_name}Number")
5504                                } else {
5505                                    format!("{context_name}NumberVariant{inline_index}")
5506                                }
5507                            }
5508                            OpenApiSchemaType::Integer => {
5509                                if inline_index == 0 {
5510                                    format!("{context_name}Integer")
5511                                } else {
5512                                    format!("{context_name}IntegerVariant{inline_index}")
5513                                }
5514                            }
5515                            OpenApiSchemaType::Boolean => {
5516                                if inline_index == 0 {
5517                                    format!("{context_name}Boolean")
5518                                } else {
5519                                    format!("{context_name}BooleanVariant{inline_index}")
5520                                }
5521                            }
5522                            _ => format!("{context_name}Variant{inline_index}"),
5523                        };
5524
5525                        let rust_type =
5526                            self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
5527
5528                        self.resolved_cache.insert(
5529                            inline_type_name.clone(),
5530                            AnalyzedSchema {
5531                                name: inline_type_name.clone(),
5532                                original: serde_json::to_value(schema).unwrap_or(Value::Null),
5533                                schema_type: SchemaType::Primitive {
5534                                    rust_type,
5535                                    serde_with: None,
5536                                },
5537                                dependencies: HashSet::new(),
5538                                nullable: false,
5539                                description: schema.details().description.clone(),
5540                                default: None,
5541                            },
5542                        );
5543
5544                        dependencies.insert(inline_type_name.clone());
5545
5546                        variants.push(SchemaRef {
5547                            target: inline_type_name,
5548                            nullable: false,
5549                        });
5550                    }
5551                }
5552            }
5553
5554            if !variants.is_empty() {
5555                return Ok(SchemaType::Union { variants });
5556            }
5557        }
5558
5559        // Pattern 3: String enum pattern (mix of "type": "string" and const values)
5560        let all_strings = any_of_schemas.iter().all(|schema| {
5561            matches!(schema.schema_type(), Some(OpenApiSchemaType::String))
5562                || schema.details().const_value.is_some()
5563        });
5564
5565        if all_strings {
5566            // Collect all constant values as enum variants
5567            let mut enum_values = Vec::new();
5568            let mut has_open_string = false;
5569
5570            for schema in any_of_schemas {
5571                // A branch may enumerate its values (`enum: [...]`), pin one
5572                // (`const`), or accept any string. The last is what makes the
5573                // union extensible rather than closed: "one of these, or
5574                // anything else" is exactly `ExtensibleEnum`.
5575                match schema
5576                    .details()
5577                    .string_enum_values()
5578                    .filter(|values| !values.is_empty())
5579                {
5580                    Some(values) => {
5581                        for value in values {
5582                            if !enum_values.contains(&value) {
5583                                enum_values.push(value);
5584                            }
5585                        }
5586                    }
5587                    None => {
5588                        if matches!(schema.schema_type(), Some(OpenApiSchemaType::String)) {
5589                            has_open_string = true;
5590                        }
5591                    }
5592                }
5593            }
5594
5595            if !enum_values.is_empty() {
5596                if has_open_string {
5597                    // Has both constants and open string - create an extensible enum
5598                    // This generates an enum with known variants plus a Custom(String) variant
5599                    return Ok(SchemaType::ExtensibleEnum {
5600                        known_values: enum_values,
5601                    });
5602                } else {
5603                    // All constants - create string enum
5604                    return Ok(SchemaType::StringEnum {
5605                        values: enum_values,
5606                    });
5607                }
5608            }
5609        }
5610
5611        // Pattern 4: Mixed primitives = fall back to serde_json::Value
5612        Ok(self.untyped_value(
5613            self.untyped_context(""),
5614            UntypedReason::UnrepresentableUnion,
5615        ))
5616    }
5617
5618    /// Find the schema with $recursiveAnchor: true for resolving $recursiveRef: "#"
5619    fn find_recursive_anchor_schema(&self) -> Option<String> {
5620        // Search through all schemas to find one with $recursiveAnchor: true
5621        for (schema_name, schema) in &self.schemas {
5622            let details = schema.details();
5623            if details.recursive_anchor == Some(true) {
5624                return Some(schema_name.clone());
5625            }
5626        }
5627
5628        // If no schema has $recursiveAnchor: true, this might be an older spec
5629        // In that case, $recursiveRef: "#" typically refers to the root schema
5630        // For now, return None to indicate we couldn't resolve it
5631        None
5632    }
5633
5634    /// Detect if a schema should use serde_json::Value for dynamic JSON
5635    /// Based on structural patterns identified in real-world APIs
5636    fn should_use_dynamic_json(&self, schema: &Schema) -> bool {
5637        // Pattern 1: anyOf with [object, null] where object has no properties
5638        if let Schema::AnyOf { any_of, .. } = schema {
5639            if any_of.len() == 2 {
5640                let has_null = any_of
5641                    .iter()
5642                    .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)));
5643                let has_empty_object = any_of.iter().any(|s| self.is_dynamic_object_pattern(s));
5644
5645                if has_null && has_empty_object {
5646                    return true;
5647                }
5648            }
5649        }
5650
5651        // Pattern 2: Direct empty object pattern
5652        self.is_dynamic_object_pattern(schema)
5653    }
5654
5655    /// Check if a schema represents a dynamic object pattern
5656    fn is_dynamic_object_pattern(&self, schema: &Schema) -> bool {
5657        // Must be object type or untyped with object inference
5658        let is_object = match schema.schema_type() {
5659            Some(OpenApiSchemaType::Object) => true,
5660            None => schema.inferred_type() == Some(OpenApiSchemaType::Object),
5661            _ => false,
5662        };
5663
5664        if !is_object {
5665            return false;
5666        }
5667
5668        let details = schema.details();
5669
5670        // If it has explicit additionalProperties, it should remain as a typed object
5671        // that will be generated as BTreeMap<String, serde_json::Value> or similar
5672        if self.has_explicit_additional_properties(schema) {
5673            return false;
5674        }
5675
5676        // Pattern 1: Object with no properties at all (and no additionalProperties)
5677        let no_properties = details
5678            .properties
5679            .as_ref()
5680            .map(|props| props.is_empty())
5681            .unwrap_or(true);
5682
5683        if no_properties {
5684            // Check for constraints that would make this a structured type.
5685            // After J5–J8, these are typed fields rather than `extra` lookups.
5686            let has_structural_constraints = details
5687                .required
5688                .as_ref()
5689                .map(|req| req.iter().any(|r| r != "type"))
5690                .unwrap_or(false)
5691                || details.pattern_properties.is_some()
5692                || details.property_names.is_some()
5693                || details.min_properties.is_some()
5694                || details.max_properties.is_some()
5695                || details.dependent_required.is_some()
5696                || details.dependent_schemas.is_some()
5697                || details.if_schema.is_some()
5698                || details.then_schema.is_some()
5699                || details.else_schema.is_some();
5700
5701            return !has_structural_constraints;
5702        }
5703
5704        false
5705    }
5706
5707    /// Check if this is an object that explicitly allows arbitrary additional properties
5708    fn has_explicit_additional_properties(&self, schema: &Schema) -> bool {
5709        let details = schema.details();
5710
5711        // Check if additionalProperties is explicitly set to true or a schema
5712        matches!(
5713            &details.additional_properties,
5714            Some(crate::openapi::AdditionalProperties::Boolean(true))
5715                | Some(crate::openapi::AdditionalProperties::Schema(_))
5716        )
5717    }
5718
5719    /// Analyze OpenAPI operations to extract request/response schemas
5720    fn analyze_operations(&mut self, analysis: &mut SchemaAnalysis) -> Result<()> {
5721        let spec: crate::openapi::OpenApiSpec = parse_spec_document(&self.openapi_spec)?;
5722        // Operation IDs are emitted into one Rust module, so collision
5723        // detection spans paths and webhooks. Index their canonical Rust type
5724        // names once instead of re-canonicalizing every previously analyzed
5725        // operation for every new endpoint.
5726        let mut canonical_operation_ids = HashSet::new();
5727
5728        if let Some(paths) = &spec.paths {
5729            for (path, path_item) in paths {
5730                // H11: Path Item may be a $ref to components/pathItems. Resolve here.
5731                let resolved = self.resolve_path_item(path_item, &spec)?;
5732                let pi: &crate::openapi::PathItem = resolved.as_ref().unwrap_or(path_item);
5733                self.ingest_path_item_operations(path, pi, analysis, &mut canonical_operation_ids)?;
5734            }
5735        }
5736        // T4: walk webhooks the same way as paths. Per OAS 3.1+, webhooks are
5737        // server→consumer callbacks: their request bodies describe payloads
5738        // the *server* sends *to* the consumer. We currently emit them as
5739        // ordinary operations so their request/response types land in the
5740        // generated client; a future bead may add a typed Webhook enum and
5741        // dispatcher.
5742        if let Some(webhooks) = &spec.webhooks {
5743            for (name, path_item) in webhooks {
5744                let synthetic_path = format!("/__webhook__/{name}");
5745                self.ingest_path_item_operations(
5746                    &synthetic_path,
5747                    path_item,
5748                    analysis,
5749                    &mut canonical_operation_ids,
5750                )?;
5751            }
5752        }
5753        Ok(())
5754    }
5755
5756    /// H11: Resolve a Path Item's `$ref` (3.1+ allows them) against
5757    /// `components/pathItems`. Returns Some(resolved) when a ref was followed,
5758    /// or None when the input is already inline.
5759    fn resolve_path_item(
5760        &self,
5761        path_item: &crate::openapi::PathItem,
5762        spec: &crate::openapi::OpenApiSpec,
5763    ) -> Result<Option<crate::openapi::PathItem>> {
5764        let Some(reference) = &path_item.reference else {
5765            return Ok(None);
5766        };
5767        let target_name = reference
5768            .strip_prefix("#/components/pathItems/")
5769            .ok_or_else(|| {
5770                GeneratorError::UnresolvedReference(format!(
5771                    "Path Item $ref must point at #/components/pathItems/{{name}}, got {reference}"
5772                ))
5773            })?;
5774        let pi = spec
5775            .components
5776            .as_ref()
5777            .and_then(|c| c.path_items.as_ref())
5778            .and_then(|map| map.get(target_name))
5779            .ok_or_else(|| {
5780                GeneratorError::UnresolvedReference(format!(
5781                    "Path Item ref {reference} not found in components/pathItems"
5782                ))
5783            })?;
5784        Ok(Some(pi.clone()))
5785    }
5786
5787    fn ingest_path_item_operations(
5788        &mut self,
5789        path: &str,
5790        path_item: &crate::openapi::PathItem,
5791        analysis: &mut SchemaAnalysis,
5792        canonical_operation_ids: &mut HashSet<String>,
5793    ) -> Result<()> {
5794        for (method, operation) in path_item.operations() {
5795            // Generate operation ID if missing.
5796            let raw_operation_id = operation
5797                .operation_id
5798                .clone()
5799                .unwrap_or_else(|| Self::generate_operation_id(method, path));
5800
5801            // T6: detect operationId collisions. Per the OAS spec these MUST
5802            // be unique, but real-world specs (arcade, cal-com, telnyx,
5803            // val-town, …) frequently aren't. Auto-disambiguate by suffixing
5804            // with the method, then a counter, and warn.
5805            //
5806            // The collision key is the PascalCased form so that case-only
5807            // differences (telnyx has `getMdrUsageReports` AND
5808            // `GetMdrUsageReports`) collide too — otherwise codegen would
5809            // produce two `GetMdrUsageReportsApiError` enums in the same
5810            // module.
5811            let operation_id = if canonical_operation_ids
5812                .contains(&Self::canonical_operation_id(&raw_operation_id))
5813            {
5814                let method_lower = method.to_lowercase();
5815                let mut candidate = format!("{}_{}", raw_operation_id, method_lower);
5816                let mut suffix = 2;
5817                while canonical_operation_ids.contains(&Self::canonical_operation_id(&candidate)) {
5818                    candidate = format!("{}_{}_{}", raw_operation_id, method_lower, suffix);
5819                    suffix += 1;
5820                }
5821                eprintln!(
5822                    "⚠️  duplicate operationId `{}` at `{} {}` — disambiguated to `{}`",
5823                    raw_operation_id, method, path, candidate
5824                );
5825                candidate
5826            } else {
5827                raw_operation_id.clone()
5828            };
5829
5830            let (op_info, responses) = self.analyze_single_operation(
5831                &operation_id,
5832                method,
5833                path,
5834                operation,
5835                path_item.parameters.as_ref(),
5836                analysis,
5837            )?;
5838            analysis
5839                .operation_id_aliases
5840                .entry(raw_operation_id)
5841                .or_default()
5842                .push(operation_id.clone());
5843            canonical_operation_ids.insert(Self::canonical_operation_id(&operation_id));
5844            analysis
5845                .operation_responses
5846                .insert(operation_id.clone(), responses);
5847            analysis.operations.insert(operation_id, op_info);
5848        }
5849        Ok(())
5850    }
5851
5852    fn canonical_operation_id(operation_id: &str) -> String {
5853        use heck::ToPascalCase;
5854        operation_id.replace('.', "_").to_pascal_case()
5855    }
5856
5857    /// Generate an operation ID from method and path when not provided
5858    /// Converts paths like "/v0/servers/{serverId}" + "get" to "getV0ServersServerId"
5859    fn generate_operation_id(method: &str, path: &str) -> String {
5860        // Start with the HTTP method in lowercase
5861        let mut operation_id = method.to_lowercase();
5862
5863        // Process the path: remove leading slash, split by /, convert to camelCase
5864        let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
5865
5866        for part in path_parts {
5867            if part.is_empty() {
5868                continue;
5869            }
5870
5871            // Handle path parameters: {serverId} -> ServerId
5872            let cleaned_part = if part.starts_with('{') && part.ends_with('}') {
5873                &part[1..part.len() - 1]
5874            } else {
5875                part
5876            };
5877
5878            // Convert to PascalCase and append
5879            let pascal_case_part = cleaned_part
5880                .split(&['-', '_'][..])
5881                .map(|s| {
5882                    let mut chars = s.chars();
5883                    match chars.next() {
5884                        None => String::new(),
5885                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
5886                    }
5887                })
5888                .collect::<String>();
5889
5890            operation_id.push_str(&pascal_case_part);
5891        }
5892
5893        operation_id
5894    }
5895
5896    /// Analyze a single OpenAPI operation
5897    fn analyze_single_operation(
5898        &mut self,
5899        operation_id: &str,
5900        method: &str,
5901        path: &str,
5902        operation: &crate::openapi::Operation,
5903        path_item_parameters: Option<&Vec<crate::openapi::Parameter>>,
5904        _analysis: &mut SchemaAnalysis,
5905    ) -> Result<(OperationInfo, BTreeMap<String, OperationResponse>)> {
5906        let raw_path_item = self
5907            .openapi_spec
5908            .get("paths")
5909            .and_then(|paths| paths.get(path))
5910            .cloned();
5911        let raw_operation = raw_path_item
5912            .as_ref()
5913            .and_then(|path_item| path_item.get(method.to_ascii_lowercase()))
5914            .cloned();
5915        let request_body = operation
5916            .request_body
5917            .as_ref()
5918            .map(|request_body| self.resolve_request_body(request_body))
5919            .transpose()?;
5920        let mut op_info = OperationInfo {
5921            operation_id: operation_id.to_string(),
5922            method: method.to_uppercase(),
5923            path: normalize_operation_path(path),
5924            summary: operation.summary.clone(),
5925            description: operation.description.clone(),
5926            request_body: None,
5927            // Per OAS 3.x §"Request Body Object", `required` defaults to false.
5928            request_body_required: request_body
5929                .as_ref()
5930                .and_then(|rb| rb.required)
5931                .unwrap_or(false),
5932            response_schemas: BTreeMap::new(),
5933            parameters: Vec::new(),
5934            supports_streaming: false, // Will be determined by StreamingConfig, not spec
5935            stream_parameter: None,    // Will be determined by StreamingConfig, not spec
5936            tags: operation.tags.clone().unwrap_or_default(),
5937        };
5938        let mut operation_responses = BTreeMap::new();
5939
5940        // Extract request body schema with content-type awareness
5941        if let Some(request_body) = &request_body {
5942            use crate::openapi::{
5943                is_binary_media_type, is_form_urlencoded_media_type, is_json_media_type,
5944                media_type_essence,
5945            };
5946            if let Some((content_type, maybe_schema)) = request_body.best_content() {
5947                op_info.request_body = if is_json_media_type(content_type) {
5948                    match maybe_schema {
5949                        Some(s) => {
5950                            let validation_schema = self
5951                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
5952                                .unwrap_or(
5953                                    serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
5954                                );
5955                            Some(
5956                                self.resolve_or_inline_schema(s, operation_id, "Request")
5957                                    .map(|name| RequestBodyContent::Json {
5958                                        schema_name: name,
5959                                        media_type: content_type.to_string(),
5960                                        validation_schema,
5961                                    })?,
5962                            )
5963                        }
5964                        None => Some(RequestBodyContent::SchemaLess {
5965                            media_type: content_type.to_string(),
5966                        }),
5967                    }
5968                } else if is_form_urlencoded_media_type(content_type) {
5969                    match maybe_schema {
5970                        Some(s) => {
5971                            let validation_schema = self
5972                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
5973                                .unwrap_or(
5974                                    serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
5975                                );
5976                            Some(
5977                                self.resolve_or_inline_schema(s, operation_id, "Request")
5978                                    .map(|name| RequestBodyContent::FormUrlEncoded {
5979                                        schema_name: name,
5980                                        media_type: content_type.to_string(),
5981                                        validation_schema,
5982                                    })?,
5983                            )
5984                        }
5985                        None => Some(RequestBodyContent::SchemaLess {
5986                            media_type: content_type.to_string(),
5987                        }),
5988                    }
5989                } else if media_type_essence(content_type)
5990                    .eq_ignore_ascii_case("multipart/form-data")
5991                {
5992                    match maybe_schema {
5993                        Some(schema) => {
5994                            let validation_schema = self
5995                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
5996                                .unwrap_or(
5997                                    serde_json::to_value(schema)
5998                                        .map_err(GeneratorError::ParseError)?,
5999                                );
6000                            Some(
6001                                self.resolve_or_inline_schema(schema, operation_id, "Request")
6002                                    .map(|schema_name| RequestBodyContent::Multipart {
6003                                        schema_name,
6004                                        media_type: content_type.to_string(),
6005                                        validation_schema,
6006                                    })?,
6007                            )
6008                        }
6009                        None => Some(RequestBodyContent::SchemaLess {
6010                            media_type: content_type.to_string(),
6011                        }),
6012                    }
6013                } else if is_binary_media_type(content_type, maybe_schema) {
6014                    if media_type_essence(content_type)
6015                        .eq_ignore_ascii_case("application/octet-stream")
6016                    {
6017                        Some(RequestBodyContent::OctetStream {
6018                            media_type: content_type.to_string(),
6019                        })
6020                    } else {
6021                        Some(RequestBodyContent::Binary {
6022                            media_type: content_type.to_string(),
6023                        })
6024                    }
6025                } else if crate::openapi::is_text_media_type(content_type) {
6026                    // Any character-data media type (text/plain, text/xml,
6027                    // application/xml, +xml suffixed) is buffered and handed
6028                    // to the handler as a lossless UTF-8 String; the server
6029                    // never parses the payload.
6030                    Some(RequestBodyContent::TextPlain {
6031                        media_type: content_type.to_string(),
6032                    })
6033                } else {
6034                    None
6035                };
6036            }
6037            if op_info.request_body.is_none() {
6038                let mut media_types = request_body
6039                    .content
6040                    .as_ref()
6041                    .map(|content| content.keys().cloned().collect::<Vec<_>>())
6042                    .unwrap_or_default();
6043                media_types.sort();
6044                if !media_types.is_empty() {
6045                    op_info.request_body = Some(RequestBodyContent::Unsupported { media_types });
6046                }
6047            }
6048        }
6049
6050        // Extract response schemas
6051        if let Some(responses) = &operation.responses {
6052            for (status_code, response) in responses {
6053                let response = self.resolve_response(response)?;
6054                // T15: SSE auto-detection. If any response declares
6055                // `text/event-stream`, mark the operation as streaming. The
6056                // user can still override via config; here we lift the spec
6057                // signal so a `stream: true` parameter and an event-stream
6058                // content type produce a streaming variant by default.
6059                let supports_streaming = response.content.as_ref().is_some_and(|content| {
6060                    content
6061                        .keys()
6062                        .any(|ct| crate::openapi::is_event_stream_media_type(ct))
6063                });
6064                if supports_streaming {
6065                    op_info.supports_streaming = true;
6066                }
6067
6068                let mut response_info = OperationResponse {
6069                    supports_streaming,
6070                    has_content: response
6071                        .content
6072                        .as_ref()
6073                        .is_some_and(|content| !content.is_empty()),
6074                    ..Default::default()
6075                };
6076                if let Some((media_type, schema)) = response.json_content() {
6077                    if let Some(schema_ref) = schema.reference() {
6078                        // Named schema reference
6079                        if let Some(schema_name) = self.extract_schema_name(schema_ref) {
6080                            op_info
6081                                .response_schemas
6082                                .insert(status_code.clone(), schema_name.to_string());
6083                            response_info.schema_name = Some(schema_name.to_string());
6084                            response_info.media_type = Some(media_type.to_string());
6085                            response_info.body = Some(OperationResponseBody::Json {
6086                                schema_name: schema_name.to_string(),
6087                                media_type: media_type.to_string(),
6088                            });
6089                        }
6090                    } else {
6091                        // Inline schema - generate a synthetic type name and analyze it
6092                        let synthetic_name =
6093                            self.generate_inline_response_type_name(operation_id, status_code);
6094
6095                        // Use the existing inline schema infrastructure
6096                        let mut deps = HashSet::new();
6097                        self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
6098
6099                        op_info
6100                            .response_schemas
6101                            .insert(status_code.clone(), synthetic_name.clone());
6102                        response_info.body = Some(OperationResponseBody::Json {
6103                            schema_name: synthetic_name.clone(),
6104                            media_type: media_type.to_string(),
6105                        });
6106                        response_info.schema_name = Some(synthetic_name);
6107                        response_info.media_type = Some(media_type.to_string());
6108                    }
6109                }
6110                if response_info.body.is_none()
6111                    && let Some(content) = response.content.as_ref()
6112                {
6113                    let selected = content
6114                        .iter()
6115                        .find(|(media_type, media)| {
6116                            matches!(
6117                                crate::openapi::classify_response_media_type(
6118                                    media_type,
6119                                    media.schema.as_ref()
6120                                ),
6121                                crate::openapi::ResponseMediaKind::Text
6122                            )
6123                        })
6124                        .or_else(|| {
6125                            content.iter().find(|(media_type, media)| {
6126                                matches!(
6127                                    crate::openapi::classify_response_media_type(
6128                                        media_type,
6129                                        media.schema.as_ref()
6130                                    ),
6131                                    crate::openapi::ResponseMediaKind::Binary
6132                                ) && !crate::openapi::is_wildcard_media_type(media_type)
6133                            })
6134                        })
6135                        .or_else(|| {
6136                            content.iter().find(|(media_type, media)| {
6137                                matches!(
6138                                    crate::openapi::classify_response_media_type(
6139                                        media_type,
6140                                        media.schema.as_ref()
6141                                    ),
6142                                    crate::openapi::ResponseMediaKind::Binary
6143                                )
6144                            })
6145                        });
6146                    if let Some((media_type, media)) = selected {
6147                        response_info.body = match crate::openapi::classify_response_media_type(
6148                            media_type,
6149                            media.schema.as_ref(),
6150                        ) {
6151                            crate::openapi::ResponseMediaKind::Text => {
6152                                Some(OperationResponseBody::Text {
6153                                    media_type: media_type.clone(),
6154                                })
6155                            }
6156                            crate::openapi::ResponseMediaKind::Binary => {
6157                                Some(OperationResponseBody::Binary {
6158                                    media_type: media_type.clone(),
6159                                    wildcard: crate::openapi::is_wildcard_media_type(media_type),
6160                                })
6161                            }
6162                            _ => None,
6163                        };
6164                    }
6165                }
6166                response_info.unsupported_media_types = response
6167                    .content
6168                    .as_ref()
6169                    .into_iter()
6170                    .flat_map(|content| content.iter())
6171                    .filter(|(media_type, content)| {
6172                        match crate::openapi::classify_response_media_type(
6173                            media_type,
6174                            content.schema.as_ref(),
6175                        ) {
6176                            crate::openapi::ResponseMediaKind::Json => content.schema.is_none(),
6177                            crate::openapi::ResponseMediaKind::Unsupported => true,
6178                            crate::openapi::ResponseMediaKind::EventStream
6179                            | crate::openapi::ResponseMediaKind::Text
6180                            | crate::openapi::ResponseMediaKind::Binary => false,
6181                        }
6182                    })
6183                    .map(|(media_type, _)| media_type.clone())
6184                    .collect();
6185                operation_responses.insert(status_code.clone(), response_info);
6186            }
6187        }
6188
6189        // T15: detect a `stream` boolean parameter on the operation; pair it
6190        // with the SSE response signal above to populate stream_parameter.
6191        if op_info.supports_streaming
6192            && let Some(parameters) = &operation.parameters
6193        {
6194            for param in parameters {
6195                if let Some(name) = param.name.as_deref() {
6196                    if name.eq_ignore_ascii_case("stream") {
6197                        op_info.stream_parameter = Some(name.to_string());
6198                        break;
6199                    }
6200                }
6201            }
6202        }
6203
6204        // Extract parameters (operation-level first, then merge path-item-level)
6205        if let Some(parameters) = &operation.parameters {
6206            for (index, param) in parameters.iter().enumerate() {
6207                // into_owned: analyze_parameter needs `&mut self` (it may
6208                // register an inline object schema for form-exploded query
6209                // params), which can't coexist with the Cow's `&self` borrow.
6210                let resolved = self.resolve_parameter(param).into_owned();
6211                let validation_schema = raw_operation
6212                    .as_ref()
6213                    .and_then(|operation| operation.get("parameters"))
6214                    .and_then(Value::as_array)
6215                    .and_then(|parameters| parameters.get(index))
6216                    .and_then(|parameter| self.raw_parameter_schema(parameter));
6217                if let Some(param_info) =
6218                    self.analyze_parameter(&resolved, operation_id, validation_schema)?
6219                {
6220                    op_info.parameters.push(param_info);
6221                }
6222            }
6223        }
6224
6225        // Merge path-item-level parameters (operation params take precedence per OpenAPI spec)
6226        if let Some(path_params) = path_item_parameters {
6227            let existing_keys: std::collections::HashSet<(String, String)> = op_info
6228                .parameters
6229                .iter()
6230                .map(|p| (p.name.clone(), p.location.clone()))
6231                .collect();
6232            for (index, param) in path_params.iter().enumerate() {
6233                let resolved = self.resolve_parameter(param).into_owned();
6234                let validation_schema = raw_path_item
6235                    .as_ref()
6236                    .and_then(|path_item| path_item.get("parameters"))
6237                    .and_then(Value::as_array)
6238                    .and_then(|parameters| parameters.get(index))
6239                    .and_then(|parameter| self.raw_parameter_schema(parameter));
6240                if let Some(param_info) =
6241                    self.analyze_parameter(&resolved, operation_id, validation_schema)?
6242                {
6243                    if !existing_keys
6244                        .contains(&(param_info.name.clone(), param_info.location.clone()))
6245                    {
6246                        op_info.parameters.push(param_info);
6247                    }
6248                }
6249            }
6250        }
6251
6252        // Synthesize path parameters that are referenced via `{var}` in the
6253        // path template but not declared as parameters in the spec.
6254        // langsmith/knocklabs/cloudflare hit this — `/repos/{owner}/{repo}/...`
6255        // declares `repo` but not `owner`. Without this, codegen emits
6256        // `format!("/repos/{owner}/...", repo)` and `owner` is undefined
6257        // (E0425). We synthesize each missing variable as a required
6258        // `String` path parameter.
6259        let mut declared_path_names: std::collections::HashSet<String> = op_info
6260            .parameters
6261            .iter()
6262            .filter(|p| p.location == "path")
6263            .map(|p| p.name.clone())
6264            .collect();
6265        let bytes = path.as_bytes().iter();
6266        let mut current = String::new();
6267        let mut in_brace = false;
6268        let mut synthesized: Vec<String> = Vec::new();
6269        for b in bytes {
6270            match *b {
6271                b'{' => {
6272                    in_brace = true;
6273                    current.clear();
6274                }
6275                b'}' if in_brace => {
6276                    in_brace = false;
6277                    if !current.is_empty() && !declared_path_names.contains(&current) {
6278                        synthesized.push(current.clone());
6279                        declared_path_names.insert(current.clone());
6280                    }
6281                }
6282                _ if in_brace => current.push(*b as char),
6283                _ => {}
6284            }
6285        }
6286        for name in synthesized {
6287            eprintln!(
6288                "⚠️  path `{}` references `{{{}}}` but the spec doesn't declare it as a parameter — synthesizing as required String",
6289                path, name
6290            );
6291            op_info.parameters.push(ParameterInfo {
6292                name,
6293                location: "path".to_string(),
6294                required: true,
6295                schema_ref: None,
6296                rust_type: "String".to_string(),
6297                description: None,
6298                enum_values: None,
6299                enum_varnames: None,
6300                rust_ident: None,
6301                query_serialization: None,
6302                validation_schema: None,
6303            });
6304        }
6305
6306        // Disambiguate Rust idents across the operation. Real-world specs
6307        // sometimes use both `kebab-case` and `snake_case` for closely-related
6308        // filter parameters (vercel: `exclude_ids` + `exclude-ids`), or
6309        // operator-suffixed forms (twilio: `StartTime`, `StartTime<`,
6310        // `StartTime>`). Without disambiguation those parameters share a
6311        // single binding and the generated body fails E0382 (use of moved
6312        // value) or E0415 (binding declared twice).
6313        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
6314        for p in op_info.parameters.iter_mut() {
6315            let raw = base_param_ident(&p.name);
6316            let mut chosen = raw.clone();
6317            let mut suffix = 2;
6318            while !used.insert(chosen.clone()) {
6319                chosen = format!("{raw}_{suffix}");
6320                suffix += 1;
6321            }
6322            p.rust_ident = Some(chosen);
6323        }
6324
6325        Ok((op_info, operation_responses))
6326    }
6327
6328    /// Resolve a local reusable Request Body Object through its JSON Pointer.
6329    fn resolve_request_body(
6330        &self,
6331        request_body: &crate::openapi::RequestBody,
6332    ) -> Result<crate::openapi::RequestBody> {
6333        let mut current = request_body.clone();
6334        let mut visited = HashSet::new();
6335        while let Some(reference) = current.reference.clone() {
6336            if !visited.insert(reference.clone()) {
6337                return Err(GeneratorError::CircularDependency(format!(
6338                    "request body reference {reference}"
6339                )));
6340            }
6341
6342            let pointer = reference.strip_prefix('#').ok_or_else(|| {
6343                GeneratorError::UnresolvedReference(format!(
6344                    "external request body reference `{reference}` is not supported"
6345                ))
6346            })?;
6347            if !pointer.is_empty() && !pointer.starts_with('/') {
6348                return Err(GeneratorError::UnresolvedReference(format!(
6349                    "request body reference `{reference}` is not a local JSON Pointer"
6350                )));
6351            }
6352            let value = self.openapi_spec.pointer(pointer).ok_or_else(|| {
6353                GeneratorError::UnresolvedReference(format!(
6354                    "request body reference `{reference}` does not exist"
6355                ))
6356            })?;
6357            let object = value.as_object().ok_or_else(|| {
6358                GeneratorError::InvalidSchema(format!(
6359                    "request body reference `{reference}` must target an object"
6360                ))
6361            })?;
6362            if !["$ref", "description", "required", "content"]
6363                .iter()
6364                .any(|field| object.contains_key(*field))
6365            {
6366                return Err(GeneratorError::InvalidSchema(format!(
6367                    "request body reference `{reference}` does not target a structurally compatible OpenAPI Request Body Object"
6368                )));
6369            }
6370            current = serde_json::from_value(value.clone()).map_err(|error| {
6371                GeneratorError::InvalidSchema(format!(
6372                    "request body reference `{reference}` is not a valid OpenAPI Request Body Object: {error}"
6373                ))
6374            })?;
6375        }
6376        Ok(current)
6377    }
6378
6379    /// Resolve a local reusable Response Object through its JSON Pointer.
6380    ///
6381    /// Real-world documents occasionally store a structurally valid Response
6382    /// Object under the wrong Components map. Resolving the pointer itself
6383    /// preserves compatibility with those documents while still validating
6384    /// that the target can be interpreted as a Response Object.
6385    fn resolve_response(
6386        &self,
6387        response: &crate::openapi::Response,
6388    ) -> Result<crate::openapi::Response> {
6389        let mut current = response.clone();
6390        let mut visited = HashSet::new();
6391        while let Some(reference) = current.reference.clone() {
6392            if !visited.insert(reference.clone()) {
6393                return Err(GeneratorError::CircularDependency(format!(
6394                    "response reference {reference}"
6395                )));
6396            }
6397
6398            let pointer = reference.strip_prefix('#').ok_or_else(|| {
6399                GeneratorError::UnresolvedReference(format!(
6400                    "external response reference `{reference}` is not supported"
6401                ))
6402            })?;
6403            if !pointer.is_empty() && !pointer.starts_with('/') {
6404                return Err(GeneratorError::UnresolvedReference(format!(
6405                    "response reference `{reference}` is not a local JSON Pointer"
6406                )));
6407            }
6408            let value = self.openapi_spec.pointer(pointer).ok_or_else(|| {
6409                GeneratorError::UnresolvedReference(format!(
6410                    "response reference `{reference}` does not exist"
6411                ))
6412            })?;
6413            let object = value.as_object().ok_or_else(|| {
6414                GeneratorError::InvalidSchema(format!(
6415                    "response reference `{reference}` must target an object"
6416                ))
6417            })?;
6418            if !["$ref", "description", "headers", "content", "links"]
6419                .iter()
6420                .any(|field| object.contains_key(*field))
6421            {
6422                return Err(GeneratorError::InvalidSchema(format!(
6423                    "response reference `{reference}` does not target a structurally compatible OpenAPI Response Object"
6424                )));
6425            }
6426            current = serde_json::from_value(value.clone()).map_err(|error| {
6427                GeneratorError::InvalidSchema(format!(
6428                    "response reference `{reference}` is not a valid OpenAPI Response Object: {error}"
6429                ))
6430            })?;
6431        }
6432        Ok(current)
6433    }
6434
6435    /// Generate a type name for an inline response schema.
6436    ///
6437    /// 200 (the canonical success status) keeps the unsuffixed `{Op}Response`
6438    /// name so simple specs and existing snapshots are unchanged. Every other
6439    /// status code is disambiguated by suffix so that multi-response operations
6440    /// (e.g. 200 + 400) don't collide in the schema registry — see issue #8.
6441    fn generate_inline_response_type_name(&self, operation_id: &str, status_code: &str) -> String {
6442        use heck::ToPascalCase;
6443        let base_name = operation_id.replace('.', "_").to_pascal_case();
6444        let suffix = Self::status_code_suffix(status_code);
6445        format!("{}Response{}", base_name, suffix)
6446    }
6447
6448    /// Map an OpenAPI status code key to a suffix for generated type names.
6449    ///
6450    /// "200" → "" (unchanged, the dominant case)
6451    /// "201", "400", "404" → "201", "400", "404"
6452    /// "default" → "Default"
6453    /// "4XX" / "4xx" → "4xx" (lowercased range form)
6454    fn status_code_suffix(status_code: &str) -> String {
6455        match status_code {
6456            "" | "200" => String::new(),
6457            "default" | "Default" => "Default".to_string(),
6458            other if other.chars().all(|c| c.is_ascii_digit()) => other.to_string(),
6459            other => other.to_ascii_lowercase(),
6460        }
6461    }
6462
6463    /// Generate a type name for an inline request body schema
6464    fn generate_inline_request_type_name(&self, operation_id: &str) -> String {
6465        use heck::ToPascalCase;
6466        // Convert operation_id to PascalCase and append Request
6467        // e.g., "session.prompt" -> "SessionPromptRequest"
6468        // e.g., "pty.create" -> "PtyCreateRequest"
6469        let base_name = operation_id.replace('.', "_").to_pascal_case();
6470        format!("{}Request", base_name)
6471    }
6472
6473    /// Resolve a schema reference to a name, or inline it with a synthetic name.
6474    /// `suffix` controls the generated name (e.g. "Request" or "Response").
6475    fn resolve_or_inline_schema(
6476        &mut self,
6477        schema: &crate::openapi::Schema,
6478        operation_id: &str,
6479        suffix: &str,
6480    ) -> Result<String> {
6481        if let Some(schema_ref) = schema.reference()
6482            && let Some(schema_name) = self.extract_schema_name(schema_ref)
6483        {
6484            return Ok(schema_name.to_string());
6485        }
6486        // Inline schema - generate a synthetic type name and analyze it
6487        let synthetic_name = if suffix == "Request" {
6488            self.generate_inline_request_type_name(operation_id)
6489        } else {
6490            self.generate_inline_response_type_name(operation_id, "")
6491        };
6492        let mut deps = HashSet::new();
6493        self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
6494        Ok(synthetic_name)
6495    }
6496
6497    /// Resolve a parameter reference ($ref) to the actual parameter definition.
6498    /// Returns the resolved parameter, or the original if it's not a reference.
6499    fn resolve_parameter<'a>(
6500        &'a self,
6501        param: &'a crate::openapi::Parameter,
6502    ) -> std::borrow::Cow<'a, crate::openapi::Parameter> {
6503        if let Some(ref_str) = param.reference.as_deref() {
6504            if let Some(param_name) = ref_str.strip_prefix("#/components/parameters/") {
6505                if let Some(resolved) = self.component_parameters.get(param_name) {
6506                    return std::borrow::Cow::Borrowed(resolved);
6507                }
6508            }
6509        }
6510        std::borrow::Cow::Borrowed(param)
6511    }
6512
6513    /// Analyze a parameter.
6514    ///
6515    /// `operation_id` is used to generate a unique synthetic enum type name
6516    /// when the parameter's inline schema is a string with `enum` or `const`
6517    /// (e.g. `GetItemTheConstant`). The client generator emits the enum
6518    /// alongside the operation methods. See issue #10 follow-up.
6519    /// Look up `#/components/schemas/{name}` in the raw OpenAPI document and
6520    /// decide whether it's a string with enum values. Used by analyze_parameter
6521    /// (T10). String-enum refs flow through to the codegen-typed parameter
6522    /// path; object refs are typed only when form-exploded (issue #27), and
6523    /// other struct refs stay `String` until deepObject / explode=false
6524    /// serialization is generated (T14).
6525    fn referenced_schema_is_string_enum(&self, name: &str) -> bool {
6526        if self.resolve_cached_schema(name).is_some_and(|schema| {
6527            matches!(
6528                schema.schema_type,
6529                SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. }
6530            )
6531        }) {
6532            return true;
6533        }
6534        let Some(schema_value) = self
6535            .openapi_spec
6536            .get("components")
6537            .and_then(|c| c.get("schemas"))
6538            .and_then(|s| s.get(name))
6539        else {
6540            return false;
6541        };
6542        let is_string_type = schema_value
6543            .get("type")
6544            .and_then(|v| v.as_str())
6545            .map(|s| s == "string")
6546            .unwrap_or(false);
6547        let has_enum_or_const =
6548            schema_value.get("enum").is_some() || schema_value.get("const").is_some();
6549        is_string_type && has_enum_or_const
6550    }
6551
6552    fn resolve_raw_local_reference(&self, value: &Value) -> Option<Value> {
6553        let Some(reference) = value.get("$ref").and_then(Value::as_str) else {
6554            return Some(value.clone());
6555        };
6556        let pointer = reference.strip_prefix('#')?;
6557        self.openapi_spec.pointer(pointer).cloned()
6558    }
6559
6560    fn raw_request_body_schema(
6561        &self,
6562        operation: Option<&Value>,
6563        content_type: &str,
6564    ) -> Option<Value> {
6565        let request_body = operation?.get("requestBody")?;
6566        self.resolve_raw_local_reference(request_body)?
6567            .get("content")?
6568            .get(content_type)?
6569            .get("schema")
6570            .cloned()
6571    }
6572
6573    fn raw_parameter_schema(&self, parameter: &Value) -> Option<Value> {
6574        self.resolve_raw_local_reference(parameter)?
6575            .get("schema")
6576            .cloned()
6577    }
6578
6579    fn analyze_parameter(
6580        &mut self,
6581        param: &crate::openapi::Parameter,
6582        operation_id: &str,
6583        raw_validation_schema: Option<Value>,
6584    ) -> Result<Option<ParameterInfo>> {
6585        use heck::ToPascalCase;
6586
6587        let name = param.name.as_deref().unwrap_or("");
6588        let location = param.location.as_deref().unwrap_or("");
6589        let required = param.required.unwrap_or(false);
6590        let validation_schema = match raw_validation_schema {
6591            Some(schema) => Some(schema),
6592            None => param
6593                .schema
6594                .as_ref()
6595                .map(serde_json::to_value)
6596                .transpose()
6597                .map_err(GeneratorError::ParseError)?,
6598        };
6599
6600        let mut rust_type = "String".to_string();
6601        let mut schema_ref = None;
6602        let mut enum_values: Option<Vec<String>> = None;
6603        let mut enum_varnames: Option<Vec<String>> = None;
6604        let mut query_serialization: Option<QuerySerialization> = None;
6605
6606        // OAS 3.x style/explode resolution for `in: query`. Defaults are
6607        // style=form and — for form only — explode=true, so an object/array
6608        // query parameter with nothing specified is already form-exploded
6609        // per spec (issue #27). deepObject is only defined with explode=true;
6610        // an explicit explode=false there is undefined and keeps the fallback.
6611        let is_query = location == "query";
6612        let is_simple_header = location == "header"
6613            && matches!(param.style.as_deref(), None | Some("simple"))
6614            && param.explode != Some(true);
6615        let form_style = matches!(param.style.as_deref(), None | Some("form"));
6616        let form_exploded = form_style && param.explode.unwrap_or(true);
6617        let deep_object =
6618            param.style.as_deref() == Some("deepObject") && param.explode != Some(false);
6619
6620        let object_serialization = if !is_query {
6621            None
6622        } else if deep_object {
6623            Some(QuerySerialization::DeepObject)
6624        } else if form_exploded {
6625            Some(QuerySerialization::FormExplodedObject)
6626        } else if form_style {
6627            Some(QuerySerialization::FormObject)
6628        } else {
6629            None
6630        };
6631
6632        if let Some(schema) = &param.schema {
6633            if let Some(ref_str) = schema.reference() {
6634                // T10: keep the resolved type when the target is a string-enum
6635                // (then `Display`/`as_str` are emitted, see generate_string_enum).
6636                // Object refs on query params with a generated wire style keep
6637                // the resolved struct type too (T14/issue #27); anything else
6638                // stays on the opaque `String` fallback.
6639                if let Some(name) = self.extract_schema_name(ref_str) {
6640                    if self.referenced_schema_is_string_enum(name) {
6641                        schema_ref = Some(name.to_string());
6642                    } else if object_serialization.is_some()
6643                        && self.referenced_schema_is_object(name)
6644                    {
6645                        schema_ref = Some(name.to_string());
6646                        query_serialization = if form_exploded && self.uses_aws_query_conventions()
6647                        {
6648                            match self.referenced_array_struct_item_type(name, 1) {
6649                                Some(ArrayItemType::NestedStructRef { properties, .. }) => {
6650                                    Some(QuerySerialization::FormExplodedNestedObject {
6651                                        properties,
6652                                    })
6653                                }
6654                                _ => object_serialization.clone(),
6655                            }
6656                        } else {
6657                            object_serialization.clone()
6658                        };
6659                    } else if (is_query && form_style || is_simple_header)
6660                        && let Some(item_type) = self.referenced_array_param_item_type(name)
6661                    {
6662                        // A parameter may reference a reusable array schema
6663                        // rather than declaring `type: array` inline. Preserve
6664                        // that component as a pruning root while projecting the
6665                        // public parameter type to the same Vec<T> used by
6666                        // inline arrays.
6667                        schema_ref = Some(name.to_string());
6668                        query_serialization = Some(if is_simple_header {
6669                            QuerySerialization::SimpleHeaderArray { item_type }
6670                        } else if form_exploded {
6671                            QuerySerialization::FormExplodedArray { item_type }
6672                        } else {
6673                            QuerySerialization::FormArray { item_type }
6674                        });
6675                    }
6676                }
6677            } else if object_serialization.is_some() && Self::schema_is_inline_object(schema) {
6678                // Inline object schema on a query parameter with a generated
6679                // wire style: synthesize a struct (e.g. `FindWidgetsFilter`)
6680                // so the caller passes typed fields instead of a pre-encoded
6681                // string.
6682                let op_pascal = operation_id.replace('.', "_").to_pascal_case();
6683                let param_pascal = name.to_pascal_case();
6684                let synthetic_name = format!("{op_pascal}{param_pascal}");
6685                let mut deps = HashSet::new();
6686                self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
6687                schema_ref = Some(synthetic_name.clone());
6688                query_serialization = if form_exploded && self.uses_aws_query_conventions() {
6689                    match self.referenced_array_struct_item_type(&synthetic_name, 1) {
6690                        Some(ArrayItemType::NestedStructRef { properties, .. }) => {
6691                            Some(QuerySerialization::FormExplodedNestedObject { properties })
6692                        }
6693                        _ => object_serialization.clone(),
6694                    }
6695                } else {
6696                    object_serialization.clone()
6697                };
6698            } else if (is_query && form_style || is_simple_header)
6699                && matches!(
6700                    schema.schema_type(),
6701                    Some(crate::openapi::SchemaType::Array)
6702                )
6703                && let Some(item_type) = self.array_param_item_type(schema)
6704            {
6705                // Typed form-style array (openapi-generator-anu): the client
6706                // takes `Vec<item_type>` and emits repeated (explode=true) or
6707                // comma-joined (explode=false) pairs. `rust_type` deliberately
6708                // stays "String" because the shared query-serialization plan
6709                // is the authoritative Vec<T> projection. Arrays whose items
6710                // don't type (objects, nested arrays) fall through to the
6711                // explicit unsupported shape below.
6712                query_serialization = Some(if is_simple_header {
6713                    QuerySerialization::SimpleHeaderArray { item_type }
6714                } else if form_exploded {
6715                    QuerySerialization::FormExplodedArray { item_type }
6716                } else {
6717                    QuerySerialization::FormArray { item_type }
6718                });
6719            } else if let Some(schema_type) = schema.schema_type() {
6720                // Route integer/number through the same TypeMapper the schema
6721                // property path uses (see analyze_property), so `format: int32`
6722                // yields `i32` and `[type_mappings]`/strategy config applies to
6723                // parameters too. Hardcoding `i64`/`f64` here previously made
6724                // `format` and config impossible to honour for query/path params.
6725                let format = schema.details().format.clone();
6726                rust_type = match schema_type {
6727                    crate::openapi::SchemaType::Boolean => "bool".to_string(),
6728                    crate::openapi::SchemaType::Integer => {
6729                        self.type_mapper.integer_format(format.as_deref()).rust_type
6730                    }
6731                    crate::openapi::SchemaType::Number => {
6732                        self.type_mapper.number_format(format.as_deref()).rust_type
6733                    }
6734                    crate::openapi::SchemaType::String => "String".to_string(),
6735                    _ => "String".to_string(),
6736                };
6737
6738                if matches!(schema_type, crate::openapi::SchemaType::String) {
6739                    let details = schema.details();
6740                    if details.is_string_enum() {
6741                        if let Some(values) = details.string_enum_values() {
6742                            if !values.is_empty() {
6743                                let op_pascal = operation_id.replace('.', "_").to_pascal_case();
6744                                let param_pascal = name.to_pascal_case();
6745                                rust_type = format!("{op_pascal}{param_pascal}");
6746                                // Honor `x-enum-varnames` here the same way
6747                                // schema-level enums do. A mismatched length is
6748                                // ambiguous about which value each name refers
6749                                // to, so drop it rather than guess.
6750                                enum_varnames = details
6751                                    .extra
6752                                    .get("x-enum-varnames")
6753                                    .and_then(Value::as_array)
6754                                    .map(|raw| {
6755                                        raw.iter()
6756                                            .filter_map(Value::as_str)
6757                                            .map(str::to_owned)
6758                                            .collect::<Vec<_>>()
6759                                    })
6760                                    .filter(|names| names.len() == values.len());
6761                                enum_values = Some(values);
6762                            }
6763                        }
6764                    }
6765                }
6766            }
6767
6768            if is_query && query_serialization.is_none() {
6769                let referenced_name = schema
6770                    .reference()
6771                    .and_then(|reference| self.extract_schema_name(reference));
6772                let is_object = referenced_name
6773                    .is_some_and(|name| self.referenced_schema_is_object(name))
6774                    || Self::schema_is_inline_object(schema);
6775                let is_array = referenced_name
6776                    .is_some_and(|name| self.referenced_schema_is_array(name))
6777                    || matches!(
6778                        schema.schema_type(),
6779                        Some(crate::openapi::SchemaType::Array)
6780                    );
6781                let is_composed = referenced_name
6782                    .is_some_and(|name| self.referenced_schema_is_composed_query_shape(name));
6783                let reason = if param.style.as_deref() == Some("deepObject")
6784                    && param.explode == Some(false)
6785                {
6786                    Some("style=deepObject with explode=false is undefined by OpenAPI".to_string())
6787                } else if param.style.as_deref() == Some("deepObject") && !is_object {
6788                    Some("style=deepObject is defined only for object query parameters".to_string())
6789                } else if is_object {
6790                    Some(format!(
6791                        "object query parameters do not support style={}",
6792                        param.style.as_deref().unwrap_or("form")
6793                    ))
6794                } else if is_array && form_style {
6795                    Some(
6796                        "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"
6797                            .to_string(),
6798                    )
6799                } else if is_array {
6800                    Some(format!(
6801                        "array query parameters do not yet support style={}",
6802                        param.style.as_deref().unwrap_or("form")
6803                    ))
6804                } else if is_composed {
6805                    Some(
6806                        "composed or union query schemas cannot be projected to an unambiguous flat wire shape"
6807                            .to_string(),
6808                    )
6809                } else {
6810                    None
6811                };
6812                if let Some(reason) = reason {
6813                    query_serialization = Some(QuerySerialization::Unsupported { reason });
6814                }
6815            }
6816        }
6817
6818        Ok(Some(ParameterInfo {
6819            name: name.to_string(),
6820            location: location.to_string(),
6821            required,
6822            schema_ref,
6823            rust_type,
6824            description: param.description.clone(),
6825            enum_values,
6826            enum_varnames,
6827            rust_ident: None,
6828            query_serialization,
6829            validation_schema,
6830        }))
6831    }
6832
6833    /// Rust item type for a typed array query parameter
6834    /// (openapi-generator-anu). Scalar items map through the TypeMapper;
6835    /// $ref items resolve when the target is a scalar alias or generated
6836    /// string enum (both support the client/server string wire projection).
6837    /// Anything else — objects, nested arrays — returns None and the
6838    /// parameter keeps the opaque-string fallback. Inline-enum'd string
6839    /// items stay plain `String`: the op-scoped enum synthesis (issue #10)
6840    /// is wired for scalar params only.
6841    fn array_param_item_type(&self, schema: &crate::openapi::Schema) -> Option<ArrayItemType> {
6842        let items = schema.details().item_schema()?;
6843        // AWS query-protocol specs wrap item refs in an annotation-only allOf
6844        // (`items: {allOf: [$ref, {xml: ...}]}`). See through the wrapper when
6845        // every sibling is annotation-only, mirroring the type-alias rule.
6846        let unwrapped = unwrap_annotation_allof(items);
6847        if let Some(ref_str) = unwrapped.reference() {
6848            let name = self.extract_schema_name(ref_str)?;
6849            return self
6850                .referenced_array_scalar_item_type(name)
6851                .or_else(|| self.referenced_array_struct_item_type(name, 1));
6852        }
6853        let format = unwrapped.details().format.clone();
6854        let scalar = match unwrapped.schema_type()? {
6855            crate::openapi::SchemaType::String => "String".to_string(),
6856            crate::openapi::SchemaType::Integer => {
6857                self.type_mapper.integer_format(format.as_deref()).rust_type
6858            }
6859            crate::openapi::SchemaType::Number => {
6860                self.type_mapper.number_format(format.as_deref()).rust_type
6861            }
6862            crate::openapi::SchemaType::Boolean => "bool".to_string(),
6863            _ => return None,
6864        };
6865        Some(ArrayItemType::Scalar(scalar))
6866    }
6867
6868    /// Resolve a reusable component array (including `$ref` aliases) and
6869    /// apply the same item projection as an inline array parameter.
6870    fn referenced_array_param_item_type(&self, name: &str) -> Option<ArrayItemType> {
6871        let schema = self.resolve_cached_schema(name)?;
6872        let SchemaType::Array { item_type } = &schema.schema_type else {
6873            return None;
6874        };
6875        self.analyzed_array_item_type(item_type)
6876    }
6877
6878    fn analyzed_array_item_type(&self, item_type: &SchemaType) -> Option<ArrayItemType> {
6879        self.analyzed_array_item_type_at_depth(item_type, 1)
6880    }
6881
6882    /// Accept a referenced structure as a form-style array item when every
6883    /// property is scalar (AWS query-protocol flat structures such as
6884    /// `Tag { Key, Value }`). Nested objects, arrays, and maps are rejected
6885    /// because the wire shape below one level is service-specific.
6886    fn referenced_array_struct_item_type(
6887        &self,
6888        name: &str,
6889        nested_array_depth: usize,
6890    ) -> Option<ArrayItemType> {
6891        let resolved = self.resolve_cached_schema(name)?;
6892        let SchemaType::Object {
6893            properties,
6894            required,
6895            additional_properties,
6896            ..
6897        } = &resolved.schema_type
6898        else {
6899            return None;
6900        };
6901        if properties.is_empty()
6902            || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
6903        {
6904            return None;
6905        }
6906        let mut projected = Vec::with_capacity(properties.len());
6907        let mut has_array = false;
6908        for (wire_name, property) in properties {
6909            let value_type = if let Some(scalar) = self.query_scalar_type(&property.schema_type) {
6910                QueryStructPropertyType::Scalar(scalar)
6911            } else {
6912                if nested_array_depth == 0 {
6913                    return None;
6914                }
6915                if let Some(array) = self.resolve_query_array_type(&property.schema_type) {
6916                    let item_type =
6917                        self.analyzed_array_item_type_at_depth(array, nested_array_depth - 1)?;
6918                    if matches!(item_type, ArrayItemType::NestedStructRef { .. }) {
6919                        return None;
6920                    }
6921                    has_array = true;
6922                    QueryStructPropertyType::Array { item_type }
6923                } else {
6924                    has_array = true;
6925                    QueryStructPropertyType::Object {
6926                        properties: self.query_flat_object_properties(&property.schema_type)?,
6927                    }
6928                }
6929            };
6930            projected.push(QueryStructProperty {
6931                wire_name: wire_name.clone(),
6932                required: required.contains(wire_name),
6933                value_type,
6934            });
6935        }
6936        if has_array {
6937            Some(ArrayItemType::NestedStructRef {
6938                schema_name: name.to_string(),
6939                properties: projected,
6940            })
6941        } else {
6942            Some(ArrayItemType::FlatStructRef {
6943                schema_name: name.to_string(),
6944                properties: projected,
6945            })
6946        }
6947    }
6948
6949    fn analyzed_array_item_type_at_depth(
6950        &self,
6951        item_type: &SchemaType,
6952        nested_array_depth: usize,
6953    ) -> Option<ArrayItemType> {
6954        match item_type {
6955            SchemaType::Primitive { rust_type, .. } => {
6956                Some(ArrayItemType::Scalar(rust_type.clone()))
6957            }
6958            SchemaType::Reference { target } => self
6959                .referenced_array_scalar_item_type(target)
6960                .or_else(|| self.referenced_array_struct_item_type(target, nested_array_depth)),
6961            _ => None,
6962        }
6963    }
6964
6965    fn resolve_query_array_type<'a>(
6966        &'a self,
6967        schema_type: &'a SchemaType,
6968    ) -> Option<&'a SchemaType> {
6969        match schema_type {
6970            SchemaType::Array { item_type } => Some(item_type),
6971            SchemaType::Reference { target } => {
6972                let resolved = self.resolve_cached_schema(target)?;
6973                let SchemaType::Array { item_type } = &resolved.schema_type else {
6974                    return None;
6975                };
6976                Some(item_type)
6977            }
6978            _ => None,
6979        }
6980    }
6981
6982    fn query_flat_object_properties(
6983        &self,
6984        schema_type: &SchemaType,
6985    ) -> Option<Vec<QueryStructProperty>> {
6986        let schema_type = match schema_type {
6987            SchemaType::Reference { target } => &self.resolve_cached_schema(target)?.schema_type,
6988            other => other,
6989        };
6990        let SchemaType::Object {
6991            properties,
6992            required,
6993            additional_properties,
6994            ..
6995        } = schema_type
6996        else {
6997            return None;
6998        };
6999        if properties.is_empty()
7000            || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden)
7001        {
7002            return None;
7003        }
7004        properties
7005            .iter()
7006            .map(|(wire_name, property)| {
7007                Some(QueryStructProperty {
7008                    wire_name: wire_name.clone(),
7009                    required: required.contains(wire_name),
7010                    value_type: QueryStructPropertyType::Scalar(
7011                        self.query_scalar_type(&property.schema_type)?,
7012                    ),
7013                })
7014            })
7015            .collect()
7016    }
7017
7018    fn query_scalar_type(&self, schema_type: &SchemaType) -> Option<QueryScalarType> {
7019        match schema_type {
7020            SchemaType::Primitive { rust_type, .. } => match rust_type.as_str() {
7021                "String" => Some(QueryScalarType::String),
7022                "bool" => Some(QueryScalarType::Boolean),
7023                value if value.starts_with('i') || value.starts_with('u') => {
7024                    Some(QueryScalarType::Integer)
7025                }
7026                value if value.starts_with('f') => Some(QueryScalarType::Number),
7027                "serde_json::Value" => None,
7028                _ => Some(QueryScalarType::String),
7029            },
7030            SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => {
7031                Some(QueryScalarType::String)
7032            }
7033            SchemaType::Reference { target } => {
7034                let resolved = self.resolve_cached_schema(target)?;
7035                self.query_scalar_type(&resolved.schema_type)
7036            }
7037            _ => None,
7038        }
7039    }
7040
7041    /// Resolve a referenced array item through any alias chain while
7042    /// preserving the outer schema name used by the public `Vec<T>` type.
7043    ///
7044    /// `SchemaType::Primitive` also represents dynamic JSON/object fallbacks,
7045    /// so require an actual OpenAPI scalar `type` before accepting it as a
7046    /// form-style query item. Unresolved and cyclic chains are rejected by
7047    /// `resolve_cached_schema`.
7048    fn referenced_array_scalar_item_type(&self, name: &str) -> Option<ArrayItemType> {
7049        let resolved = self.resolve_cached_schema(name)?;
7050        let supported = match &resolved.schema_type {
7051            SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => true,
7052            SchemaType::Primitive { .. } => resolved
7053                .original
7054                .get("type")
7055                .is_some_and(Self::query_scalar_type_value),
7056            _ => false,
7057        };
7058        supported.then(|| ArrayItemType::SchemaRef(name.to_string()))
7059    }
7060
7061    fn query_scalar_type_value(value: &Value) -> bool {
7062        const SCALARS: [&str; 4] = ["string", "integer", "number", "boolean"];
7063        if let Some(value) = value.as_str() {
7064            return SCALARS.contains(&value);
7065        }
7066        let Some(values) = value.as_array() else {
7067            return false;
7068        };
7069        if !values.iter().all(Value::is_string) {
7070            return false;
7071        }
7072        let mut non_null = values
7073            .iter()
7074            .filter_map(Value::as_str)
7075            .filter(|value| *value != "null");
7076        let Some(scalar) = non_null.next() else {
7077            return false;
7078        };
7079        non_null.next().is_none() && SCALARS.contains(&scalar)
7080    }
7081
7082    /// True when a component (following `$ref` aliases) analyzes to an object.
7083    /// Used to decide whether a referenced query parameter can use a typed
7084    /// object serialization plan (issue #27).
7085    fn referenced_schema_is_object(&self, name: &str) -> bool {
7086        self.resolve_cached_schema(name)
7087            .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Object { .. }))
7088    }
7089
7090    fn referenced_schema_is_array(&self, name: &str) -> bool {
7091        self.resolve_cached_schema(name)
7092            .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Array { .. }))
7093    }
7094
7095    fn referenced_schema_is_composed_query_shape(&self, name: &str) -> bool {
7096        self.resolve_cached_schema(name).is_some_and(|schema| {
7097            matches!(
7098                schema.schema_type,
7099                SchemaType::Composition { .. }
7100                    | SchemaType::Union { .. }
7101                    | SchemaType::DiscriminatedUnion { .. }
7102            )
7103        })
7104    }
7105
7106    fn resolve_cached_schema(&self, name: &str) -> Option<&AnalyzedSchema> {
7107        let mut current = name;
7108        let mut visited = HashSet::new();
7109        loop {
7110            if !visited.insert(current) {
7111                return None;
7112            }
7113            let schema = self.resolved_cache.get(current)?;
7114            if let SchemaType::Reference { target } = &schema.schema_type {
7115                current = target;
7116            } else {
7117                return Some(schema);
7118            }
7119        }
7120    }
7121
7122    /// Inline-schema counterpart of [`Self::referenced_schema_is_object`].
7123    fn schema_is_inline_object(schema: &crate::openapi::Schema) -> bool {
7124        match schema.schema_type() {
7125            Some(crate::openapi::SchemaType::Object) => true,
7126            None => schema.details().properties.is_some(),
7127            _ => false,
7128        }
7129    }
7130}
7131
7132/// Deserialize the whole document, locating any failure to the node that
7133/// caused it.
7134///
7135/// `serde_json::from_value` reports only serde's innermost message — for the
7136/// untagged `Schema` enum that is "data did not match any variant of untagged
7137/// enum Schema", with no field, schema name, or position. Tracking the path
7138/// turns that into a JSON Pointer the author can jump straight to (issue #60).
7139/// A Rust type name for a JSON Pointer target, e.g.
7140/// `/components/schemas/Tag/allOf/0` becomes `TagAllOf0`. The `components`
7141/// section prefix carries no information and is dropped.
7142fn pointer_type_name(pointer: &str) -> String {
7143    use heck::ToPascalCase;
7144
7145    pointer
7146        .split('/')
7147        .skip(1)
7148        .filter(|segment| !matches!(*segment, "components" | "schemas" | "properties"))
7149        .map(|segment| {
7150            segment
7151                .replace("~1", "/")
7152                .replace("~0", "~")
7153                .to_pascal_case()
7154        })
7155        .collect::<String>()
7156}
7157
7158/// The one schema every position shares, if they are interchangeable: the same
7159/// `$ref`, or the same primitive type and format. Inline objects never qualify
7160/// — two structurally identical inline objects still hoist two named types, so
7161/// treating them as one element type would silently drop a generated name.
7162fn shared_positional_schema(positions: &[Schema]) -> Option<&Schema> {
7163    let first = positions.first()?;
7164    let key = positional_schema_key(first)?;
7165    positions
7166        .iter()
7167        .skip(1)
7168        .all(|position| positional_schema_key(position).as_deref() == Some(key.as_str()))
7169        .then_some(first)
7170}
7171
7172fn positional_schema_key(schema: &Schema) -> Option<String> {
7173    if let Some(reference) = schema.reference() {
7174        return Some(format!("$ref {reference}"));
7175    }
7176    let details = schema.details();
7177    if details.properties.is_some() || details.enum_values.is_some() || details.items.is_some() {
7178        return None;
7179    }
7180    match schema.schema_type()? {
7181        crate::openapi::SchemaType::Object | crate::openapi::SchemaType::Array => None,
7182        scalar => Some(format!(
7183            "{scalar:?} {}",
7184            details.format.as_deref().unwrap_or_default()
7185        )),
7186    }
7187}
7188
7189fn parse_spec_document(openapi_spec: &Value) -> Result<OpenApiSpec> {
7190    serde_path_to_error::deserialize(openapi_spec).map_err(|error| {
7191        let mut pointer = json_pointer(error.path());
7192        // Untagged enums deserialize from a buffered copy, so serde's path
7193        // stops at the outermost `Schema` — usually the component schema.
7194        // Walk the failing subtree to name the node that actually failed.
7195        pointer.push_str(&refine_schema_failure(openapi_spec, &pointer));
7196        GeneratorError::ParseErrorAt {
7197            pointer,
7198            message: error.into_inner().to_string(),
7199        }
7200    })
7201}
7202
7203/// Schema keywords holding a single subschema.
7204const SUBSCHEMA_KEYWORDS: [&str; 11] = [
7205    "items",
7206    "additionalProperties",
7207    "propertyNames",
7208    "unevaluatedProperties",
7209    "unevaluatedItems",
7210    "contains",
7211    "contentSchema",
7212    "if",
7213    "then",
7214    "else",
7215    "not",
7216];
7217
7218/// Schema keywords holding a list of subschemas.
7219const SUBSCHEMA_LIST_KEYWORDS: [&str; 4] = ["oneOf", "anyOf", "allOf", "prefixItems"];
7220
7221/// Schema keywords holding a map of named subschemas.
7222const SUBSCHEMA_MAP_KEYWORDS: [&str; 5] = [
7223    "properties",
7224    "patternProperties",
7225    "dependentSchemas",
7226    "$defs",
7227    "definitions",
7228];
7229
7230/// Budget on parse attempts while refining a located failure. Refinement runs
7231/// only on the error path, but a multi-megabyte document should still not turn
7232/// one bad keyword into an unbounded search.
7233const REFINE_PARSE_BUDGET: usize = 20_000;
7234
7235/// Extend a located parse failure with the pointer suffix of the malformed
7236/// schema below it, so the reported pointer names the offending keyword rather
7237/// than the enclosing component schema, path item, or `paths` map.
7238///
7239/// Serde's own path stops at the first `#[serde(flatten)]` or untagged enum it
7240/// buffers through — for a document that is `#/paths` or the component schema —
7241/// so the rest of the descent happens here.
7242fn refine_schema_failure(openapi_spec: &Value, pointer: &str) -> String {
7243    let Some(path) = pointer.strip_prefix('#') else {
7244        return String::new();
7245    };
7246    let Some(node) = openapi_spec.pointer(path) else {
7247        return String::new();
7248    };
7249    let segments = path.split('/').skip(1).collect::<Vec<_>>();
7250    let last = segments.last().copied().unwrap_or_default();
7251    let parent = segments
7252        .len()
7253        .checked_sub(2)
7254        .map(|index| segments[index])
7255        .unwrap_or_default();
7256
7257    if last == "schema" || holds_schemas(parent) {
7258        return if parses_as_schema(node) {
7259            String::new()
7260        } else {
7261            deepest_schema_failure(node)
7262        };
7263    }
7264
7265    let mut budget = REFINE_PARSE_BUDGET;
7266    locate_failing_schema(node, holds_schemas(last), &mut budget).unwrap_or_default()
7267}
7268
7269/// Whether a key's members are schemas: the Components `schemas` map and the
7270/// JSON Schema `$defs` / `definitions` maps.
7271fn holds_schemas(key: &str) -> bool {
7272    matches!(key, "schemas" | "$defs" | "definitions")
7273}
7274
7275/// Walk OpenAPI structure looking for the malformed schema, then drill into it
7276/// keyword-first.
7277///
7278/// Only nodes in a schema position are tested. Guessing from shape does not
7279/// work: a `properties` map whose single property is named `properties` is
7280/// indistinguishable from a schema by its keys alone, and fails to parse as one
7281/// — naming it would point the author at a node that is perfectly valid.
7282fn locate_failing_schema(
7283    node: &Value,
7284    children_are_schemas: bool,
7285    budget: &mut usize,
7286) -> Option<String> {
7287    for (segment, key, child) in child_nodes(node) {
7288        if *budget == 0 {
7289            return None;
7290        }
7291        *budget -= 1;
7292        if children_are_schemas || key == "schema" {
7293            if !parses_as_schema(child) {
7294                return Some(format!("/{segment}{}", deepest_schema_failure(child)));
7295            }
7296            continue;
7297        }
7298        if let Some(rest) = locate_failing_schema(child, holds_schemas(key), budget) {
7299            return Some(format!("/{segment}{rest}"));
7300        }
7301    }
7302    None
7303}
7304
7305fn parses_as_schema(node: &Value) -> bool {
7306    Schema::deserialize(node).is_ok()
7307}
7308
7309/// Depth-first search inside a malformed schema for the deepest subschema that
7310/// also fails to parse, so the pointer names the offending keyword rather than
7311/// the schema that contains it. The caller guarantees `node` already failed.
7312fn deepest_schema_failure(node: &Value) -> String {
7313    let Some(object) = node.as_object() else {
7314        return String::new();
7315    };
7316
7317    let descend = |segment: String, child: &Value| -> Option<String> {
7318        if parses_as_schema(child) {
7319            return None;
7320        }
7321        Some(format!("/{segment}{}", deepest_schema_failure(child)))
7322    };
7323
7324    for keyword in SUBSCHEMA_KEYWORDS {
7325        if let Some(child) = object.get(keyword)
7326            && let Some(suffix) = descend(escape_pointer_segment(keyword), child)
7327        {
7328            return suffix;
7329        }
7330    }
7331    for keyword in SUBSCHEMA_LIST_KEYWORDS {
7332        if let Some(Value::Array(children)) = object.get(keyword) {
7333            for (index, child) in children.iter().enumerate() {
7334                if let Some(suffix) = descend(
7335                    format!("{}/{index}", escape_pointer_segment(keyword)),
7336                    child,
7337                ) {
7338                    return suffix;
7339                }
7340            }
7341        }
7342    }
7343    for keyword in SUBSCHEMA_MAP_KEYWORDS {
7344        if let Some(Value::Object(children)) = object.get(keyword) {
7345            for (name, child) in children {
7346                if let Some(suffix) = descend(
7347                    format!(
7348                        "{}/{}",
7349                        escape_pointer_segment(keyword),
7350                        escape_pointer_segment(name)
7351                    ),
7352                    child,
7353                ) {
7354                    return suffix;
7355                }
7356            }
7357        }
7358    }
7359    String::new()
7360}
7361
7362/// Object members and array elements, paired with their JSON Pointer segment
7363/// and raw key. Scalars have no children and are skipped, as are members that
7364/// hold data rather than schemas: an `x-` extension or an `example` payload is
7365/// free-form JSON that never fails to deserialize, so anything schema-shaped
7366/// found in there is a coincidence, not the failure being located.
7367fn child_nodes(node: &Value) -> Vec<(String, &str, &Value)> {
7368    const DATA_KEYWORDS: [&str; 5] = ["example", "examples", "default", "enum", "const"];
7369
7370    match node {
7371        Value::Object(members) => members
7372            .iter()
7373            .filter(|(name, child)| {
7374                (child.is_object() || child.is_array())
7375                    && !name.starts_with("x-")
7376                    && !DATA_KEYWORDS.contains(&name.as_str())
7377            })
7378            .map(|(name, child)| (escape_pointer_segment(name), name.as_str(), child))
7379            .collect(),
7380        Value::Array(elements) => elements
7381            .iter()
7382            .enumerate()
7383            .filter(|(_, child)| child.is_object() || child.is_array())
7384            .map(|(index, child)| (index.to_string(), "", child))
7385            .collect(),
7386        _ => Vec::new(),
7387    }
7388}
7389
7390fn escape_pointer_segment(segment: &str) -> String {
7391    segment.replace('~', "~0").replace('/', "~1")
7392}
7393
7394/// Render a serde path as an RFC 6901 JSON Pointer (`#/components/schemas/Foo`)
7395/// so it can be pasted into any spec tooling. `~` and `/` inside a key are
7396/// escaped per the RFC.
7397fn json_pointer(path: &serde_path_to_error::Path) -> String {
7398    use serde_path_to_error::Segment;
7399
7400    let mut pointer = String::from("#");
7401    for segment in path.iter() {
7402        match segment {
7403            Segment::Seq { index } => {
7404                pointer.push('/');
7405                pointer.push_str(&index.to_string());
7406            }
7407            Segment::Map { key } | Segment::Enum { variant: key } => {
7408                pointer.push('/');
7409                pointer.push_str(&escape_pointer_segment(key));
7410            }
7411            Segment::Unknown => pointer.push_str("/?"),
7412        }
7413    }
7414    pointer
7415}
7416
7417fn disambiguate_component_schema_names(openapi_spec: &mut Value) {
7418    let Some(schemas) = openapi_spec
7419        .pointer_mut("/components/schemas")
7420        .and_then(Value::as_object_mut)
7421    else {
7422        return;
7423    };
7424
7425    let mut names_by_rust_name = BTreeMap::<String, Vec<String>>::new();
7426    for name in schemas.keys() {
7427        names_by_rust_name
7428            .entry(crate::generator::rust_type_name(name))
7429            .or_default()
7430            .push(name.clone());
7431    }
7432
7433    // Reserve every identifier already represented by the document so a
7434    // suffix never steals another component's canonical Rust name.
7435    let mut claimed_rust_names = names_by_rust_name.keys().cloned().collect::<HashSet<_>>();
7436    let mut aliases = BTreeMap::<String, String>::new();
7437
7438    for (rust_name, mut names) in names_by_rust_name {
7439        if names.len() < 2 {
7440            continue;
7441        }
7442
7443        // Prefer an already-canonical component key (for example `Alert`
7444        // over `alert`), then use lexical order for deterministic results.
7445        names.sort_by_key(|name| (name != &rust_name, name.clone()));
7446        for source_name in names.into_iter().skip(1) {
7447            let mut suffix = 2;
7448            let replacement = loop {
7449                let candidate = format!("{rust_name}{suffix}");
7450                if claimed_rust_names.insert(candidate.clone()) {
7451                    break candidate;
7452                }
7453                suffix += 1;
7454            };
7455
7456            eprintln!(
7457                "⚠️  schema `{source_name}` maps to the existing Rust type `{rust_name}` — disambiguated to `{replacement}`"
7458            );
7459            aliases.insert(source_name, replacement);
7460        }
7461    }
7462
7463    if aliases.is_empty() {
7464        return;
7465    }
7466
7467    let original_schemas = std::mem::take(schemas);
7468    for (name, schema) in original_schemas {
7469        schemas.insert(aliases.get(&name).cloned().unwrap_or(name), schema);
7470    }
7471
7472    rewrite_component_schema_references(openapi_spec, &aliases);
7473}
7474
7475fn disambiguate_analyzed_schema_names(
7476    analysis: &mut SchemaAnalysis,
7477    component_schemas: &BTreeMap<String, Schema>,
7478) {
7479    let mut names_by_rust_name = BTreeMap::<String, Vec<String>>::new();
7480    for name in analysis.schemas.keys() {
7481        names_by_rust_name
7482            .entry(crate::generator::rust_type_name(name))
7483            .or_default()
7484            .push(name.clone());
7485    }
7486
7487    let mut claimed_rust_names = names_by_rust_name.keys().cloned().collect::<HashSet<_>>();
7488    let mut aliases = BTreeMap::<String, String>::new();
7489
7490    for (rust_name, mut names) in names_by_rust_name {
7491        if names.len() < 2 {
7492            continue;
7493        }
7494        names.sort_by_key(|name| {
7495            (
7496                !component_schemas.contains_key(name),
7497                name != &rust_name,
7498                name.clone(),
7499            )
7500        });
7501
7502        for source_name in names.into_iter().skip(1) {
7503            let mut suffix = 2;
7504            let replacement = loop {
7505                let candidate = format!("{rust_name}{suffix}");
7506                if claimed_rust_names.insert(candidate.clone()) {
7507                    break candidate;
7508                }
7509                suffix += 1;
7510            };
7511            eprintln!(
7512                "⚠️  generated schema `{source_name}` maps to the existing Rust type `{rust_name}` — disambiguated to `{replacement}`"
7513            );
7514            aliases.insert(source_name, replacement);
7515        }
7516    }
7517
7518    if aliases.is_empty() {
7519        return;
7520    }
7521
7522    let original_schemas = std::mem::take(&mut analysis.schemas);
7523    for (name, mut schema) in original_schemas {
7524        schema.name = renamed_schema_name(&schema.name, &aliases);
7525        schema.dependencies = schema
7526            .dependencies
7527            .into_iter()
7528            .map(|name| renamed_schema_name(&name, &aliases))
7529            .collect();
7530        rewrite_schema_type_names(&mut schema.schema_type, &aliases);
7531        analysis
7532            .schemas
7533            .insert(renamed_schema_name(&name, &aliases), schema);
7534    }
7535
7536    let original_edges = std::mem::take(&mut analysis.dependencies.edges);
7537    for (name, dependencies) in original_edges {
7538        analysis.dependencies.edges.insert(
7539            renamed_schema_name(&name, &aliases),
7540            dependencies
7541                .into_iter()
7542                .map(|name| renamed_schema_name(&name, &aliases))
7543                .collect(),
7544        );
7545    }
7546    analysis.dependencies.recursive_schemas = analysis
7547        .dependencies
7548        .recursive_schemas
7549        .iter()
7550        .map(|name| renamed_schema_name(name, &aliases))
7551        .collect();
7552
7553    analysis.patterns.tagged_enum_schemas = analysis
7554        .patterns
7555        .tagged_enum_schemas
7556        .iter()
7557        .map(|name| renamed_schema_name(name, &aliases))
7558        .collect();
7559    analysis.patterns.untagged_enum_schemas = analysis
7560        .patterns
7561        .untagged_enum_schemas
7562        .iter()
7563        .map(|name| renamed_schema_name(name, &aliases))
7564        .collect();
7565    analysis.patterns.type_mappings = std::mem::take(&mut analysis.patterns.type_mappings)
7566        .into_iter()
7567        .map(|(name, mappings)| {
7568            (
7569                renamed_schema_name(&name, &aliases),
7570                mappings
7571                    .into_iter()
7572                    .map(|(value, schema_name)| {
7573                        (value, renamed_schema_name(&schema_name, &aliases))
7574                    })
7575                    .collect(),
7576            )
7577        })
7578        .collect();
7579
7580    for operation in analysis.operations.values_mut() {
7581        if let Some(request_body) = &mut operation.request_body {
7582            rewrite_request_body_schema_name(request_body, &aliases);
7583        }
7584        for schema_name in operation.response_schemas.values_mut() {
7585            *schema_name = renamed_schema_name(schema_name, &aliases);
7586        }
7587        for parameter in &mut operation.parameters {
7588            if let Some(schema_name) = &mut parameter.schema_ref {
7589                *schema_name = renamed_schema_name(schema_name, &aliases);
7590            }
7591            if let Some(serialization) = &mut parameter.query_serialization {
7592                rewrite_query_serialization_schema_names(serialization, &aliases);
7593            }
7594        }
7595    }
7596
7597    for responses in analysis.operation_responses.values_mut() {
7598        for response in responses.values_mut() {
7599            if let Some(schema_name) = &mut response.schema_name {
7600                *schema_name = renamed_schema_name(schema_name, &aliases);
7601            }
7602            if let Some(OperationResponseBody::Json { schema_name, .. }) = &mut response.body {
7603                *schema_name = renamed_schema_name(schema_name, &aliases);
7604            }
7605        }
7606    }
7607}
7608
7609fn renamed_schema_name(name: &str, aliases: &BTreeMap<String, String>) -> String {
7610    aliases
7611        .get(name)
7612        .cloned()
7613        .unwrap_or_else(|| name.to_string())
7614}
7615
7616fn rewrite_schema_type_names(schema_type: &mut SchemaType, aliases: &BTreeMap<String, String>) {
7617    match schema_type {
7618        SchemaType::Object {
7619            properties,
7620            additional_properties,
7621            ..
7622        } => {
7623            for property in properties.values_mut() {
7624                rewrite_schema_type_names(&mut property.schema_type, aliases);
7625            }
7626            if let ObjectAdditionalProperties::Typed { value_type } = additional_properties {
7627                rewrite_schema_type_names(value_type, aliases);
7628            }
7629        }
7630        SchemaType::DiscriminatedUnion { variants, .. } => {
7631            for variant in variants {
7632                variant.type_name = renamed_schema_name(&variant.type_name, aliases);
7633                variant.schema_ref = renamed_schema_name(&variant.schema_ref, aliases);
7634            }
7635        }
7636        SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => {
7637            for variant in variants {
7638                variant.target = renamed_schema_name(&variant.target, aliases);
7639            }
7640        }
7641        SchemaType::Array { item_type } => rewrite_schema_type_names(item_type, aliases),
7642        SchemaType::Untyped { .. } => {}
7643        SchemaType::Tuple { element_types } => {
7644            for element_type in element_types {
7645                rewrite_schema_type_names(element_type, aliases);
7646            }
7647        }
7648        SchemaType::Reference { target } => {
7649            *target = renamed_schema_name(target, aliases);
7650        }
7651        SchemaType::Primitive { .. }
7652        | SchemaType::StringEnum { .. }
7653        | SchemaType::ExtensibleEnum { .. } => {}
7654    }
7655}
7656
7657fn rewrite_request_body_schema_name(
7658    request_body: &mut RequestBodyContent,
7659    aliases: &BTreeMap<String, String>,
7660) {
7661    match request_body {
7662        RequestBodyContent::Json { schema_name, .. }
7663        | RequestBodyContent::FormUrlEncoded { schema_name, .. }
7664        | RequestBodyContent::Multipart { schema_name, .. } => {
7665            *schema_name = renamed_schema_name(schema_name, aliases);
7666        }
7667        _ => {}
7668    }
7669}
7670
7671fn rewrite_query_serialization_schema_names(
7672    serialization: &mut QuerySerialization,
7673    aliases: &BTreeMap<String, String>,
7674) {
7675    match serialization {
7676        QuerySerialization::FormExplodedArray { item_type }
7677        | QuerySerialization::FormArray { item_type }
7678        | QuerySerialization::SimpleHeaderArray { item_type } => {
7679            rewrite_array_item_type_schema_names(item_type, aliases);
7680        }
7681        QuerySerialization::FormExplodedNestedObject { properties } => {
7682            for property in properties {
7683                rewrite_query_property_type_schema_names(&mut property.value_type, aliases);
7684            }
7685        }
7686        _ => {}
7687    }
7688}
7689
7690fn rewrite_array_item_type_schema_names(
7691    item_type: &mut ArrayItemType,
7692    aliases: &BTreeMap<String, String>,
7693) {
7694    match item_type {
7695        ArrayItemType::SchemaRef(name) => *name = renamed_schema_name(name, aliases),
7696        ArrayItemType::FlatStructRef {
7697            schema_name,
7698            properties,
7699        }
7700        | ArrayItemType::NestedStructRef {
7701            schema_name,
7702            properties,
7703        } => {
7704            *schema_name = renamed_schema_name(schema_name, aliases);
7705            for property in properties {
7706                rewrite_query_property_type_schema_names(&mut property.value_type, aliases);
7707            }
7708        }
7709        ArrayItemType::Scalar(_) => {}
7710    }
7711}
7712
7713fn rewrite_query_property_type_schema_names(
7714    property_type: &mut QueryStructPropertyType,
7715    aliases: &BTreeMap<String, String>,
7716) {
7717    match property_type {
7718        QueryStructPropertyType::Array { item_type } => {
7719            rewrite_array_item_type_schema_names(item_type, aliases)
7720        }
7721        QueryStructPropertyType::Object { properties } => {
7722            for property in properties {
7723                rewrite_query_property_type_schema_names(&mut property.value_type, aliases);
7724            }
7725        }
7726        QueryStructPropertyType::Scalar(_) => {}
7727    }
7728}
7729
7730fn rewrite_component_schema_references(value: &mut Value, aliases: &BTreeMap<String, String>) {
7731    match value {
7732        Value::Array(values) => {
7733            for value in values {
7734                rewrite_component_schema_references(value, aliases);
7735            }
7736        }
7737        Value::Object(object) => {
7738            if let Some(Value::String(reference)) = object.get_mut("$ref") {
7739                rewrite_component_schema_reference(reference, aliases);
7740            }
7741
7742            if let Some(Value::Object(mapping)) = object.get_mut("mapping") {
7743                for target_value in mapping.values_mut() {
7744                    let Some(target) = target_value.as_str() else {
7745                        continue;
7746                    };
7747                    let replacement = aliases.get(target).cloned().or_else(|| {
7748                        let mut target = target.to_string();
7749                        rewrite_component_schema_reference(&mut target, aliases).then_some(target)
7750                    });
7751                    if let Some(replacement) = replacement {
7752                        *target_value = Value::String(replacement);
7753                    }
7754                }
7755            }
7756
7757            for value in object.values_mut() {
7758                rewrite_component_schema_references(value, aliases);
7759            }
7760        }
7761        _ => {}
7762    }
7763}
7764
7765fn rewrite_component_schema_reference(
7766    reference: &mut String,
7767    aliases: &BTreeMap<String, String>,
7768) -> bool {
7769    const PREFIX: &str = "#/components/schemas/";
7770    let Some(encoded_name) = reference.strip_prefix(PREFIX) else {
7771        return false;
7772    };
7773    let encoded_name = encoded_name.split('/').next().unwrap_or(encoded_name);
7774
7775    for (source, replacement) in aliases {
7776        let encoded_source = source.replace('~', "~0").replace('/', "~1");
7777        if encoded_name == encoded_source {
7778            reference.replace_range(
7779                PREFIX.len()..PREFIX.len() + encoded_source.len(),
7780                replacement,
7781            );
7782            return true;
7783        }
7784    }
7785
7786    false
7787}