Skip to main content

openapi_to_rust/
analysis.rs

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