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