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