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