Skip to main content

openapi_to_rust/
analysis.rs

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