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