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_json::Value;
5use std::collections::{BTreeMap, HashSet};
6use std::path::Path;
7
8/// Q2.6 — pull `x-enum-varnames` / `x-enum-descriptions` arrays off
9/// the schema's original JSON. Both extensions must be string arrays
10/// matching the enum-value count; mismatched extensions are dropped
11/// with a stderr warning so they can't subtly break codegen.
12///
13/// Returns `None` when neither extension is present.
14fn extract_enum_extensions(
15    original: &Value,
16    enum_value_count: usize,
17    schema_name: &str,
18) -> Option<EnumExtensions> {
19    let obj = original.as_object()?;
20
21    let read_string_array = |key: &str| -> Option<Vec<String>> {
22        let arr = obj.get(key)?.as_array()?;
23        let mut out = Vec::with_capacity(arr.len());
24        for v in arr {
25            out.push(v.as_str()?.to_string());
26        }
27        Some(out)
28    };
29
30    let varnames_raw = read_string_array("x-enum-varnames");
31    let descriptions_raw = read_string_array("x-enum-descriptions");
32
33    if varnames_raw.is_none() && descriptions_raw.is_none() {
34        return None;
35    }
36
37    let validate = |label: &str, vals: Option<Vec<String>>| -> Vec<String> {
38        let Some(vals) = vals else {
39            return Vec::new();
40        };
41        if vals.len() == enum_value_count {
42            vals
43        } else {
44            eprintln!(
45                "⚠️  {schema_name}: dropping {label} (expected {enum_value_count} entries, got {})",
46                vals.len()
47            );
48            Vec::new()
49        }
50    };
51
52    let varnames = validate("x-enum-varnames", varnames_raw);
53    let descriptions = validate("x-enum-descriptions", descriptions_raw);
54
55    if varnames.is_empty() && descriptions.is_empty() {
56        return None;
57    }
58    Some(EnumExtensions {
59        varnames,
60        descriptions,
61    })
62}
63
64#[derive(Debug, Clone)]
65pub struct SchemaAnalysis {
66    /// All schemas indexed by name
67    pub schemas: BTreeMap<String, AnalyzedSchema>,
68    /// Dependency graph for generation ordering
69    pub dependencies: DependencyGraph,
70    /// Detected patterns and transformations
71    pub patterns: DetectedPatterns,
72    /// OpenAPI operations and their request/response schemas
73    pub operations: BTreeMap<String, OperationInfo>,
74    /// Source operationId to emitted operation IDs. Duplicate or
75    /// Rust-identifier-colliding IDs are renamed during analysis; retaining
76    /// this mapping lets selector resolution report ambiguity or renaming.
77    pub operation_id_aliases: BTreeMap<String, Vec<String>>,
78    /// Optional crates the [`TypeMapper`] was asked to reference
79    /// during analysis (e.g. chrono when a `format: date-time` field
80    /// became `chrono::DateTime<Utc>`). The generator reads this to
81    /// decide which helper modules (e.g. `base64_serde`) to emit. Complete
82    /// dependency reporting is collected from retained emitted files so
83    /// pruned schemas cannot leak stale requirements.
84    ///
85    /// [`TypeMapper`]: crate::type_mapping::TypeMapper
86    pub used_type_features: crate::type_mapping::UsedFeatures,
87    /// Q2.6: per-schema vendor enum extensions
88    /// (`x-enum-varnames` / `x-enum-descriptions`). Populated during
89    /// analysis when a StringEnum / ExtensibleEnum schema declares
90    /// either extension; the generator uses these to override the
91    /// default heuristic variant names and emit per-variant doc
92    /// comments. Indexed by analyzed-schema name. Side-channel so we
93    /// don't have to touch every StringEnum constructor.
94    pub enum_extensions: BTreeMap<String, EnumExtensions>,
95    /// Raw, unpruned schema material used to build offline server validators.
96    /// This is deliberately independent of `schemas`, which model pruning may
97    /// mutate before server artifacts are emitted.
98    pub validation_context: ValidationContext,
99}
100
101#[derive(Debug, Clone, Default)]
102pub struct ValidationContext {
103    pub openapi_version: String,
104    pub json_schema_dialect: Option<String>,
105    pub component_schemas: BTreeMap<String, Value>,
106}
107
108/// Q2.6 — vendor extensions describing a string enum's variant
109/// names and per-variant descriptions. Length must match the
110/// schema's `enum` array; mismatched extensions are dropped at
111/// analysis time with a warning.
112#[derive(Debug, Clone, Default)]
113pub struct EnumExtensions {
114    /// `x-enum-varnames`: Rust-friendly variant identifiers per
115    /// enum value, in the same order as the spec's `enum` array.
116    /// When present and length matches, the generator uses these
117    /// instead of its default PascalCase heuristic.
118    pub varnames: Vec<String>,
119    /// `x-enum-descriptions`: one doc-comment per enum value.
120    pub descriptions: Vec<String>,
121}
122
123#[derive(Debug, Clone)]
124pub struct AnalyzedSchema {
125    pub name: String,
126    pub original: Value,
127    pub schema_type: SchemaType,
128    pub dependencies: HashSet<String>,
129    pub nullable: bool,
130    pub description: Option<String>,
131    pub default: Option<serde_json::Value>,
132}
133
134#[derive(Debug, Clone)]
135pub enum SchemaType {
136    /// Simple primitive type. `serde_with` carries an optional
137    /// `#[serde(with = "<path>")]` codec hint produced by the
138    /// TypeMapper for typed scalars (e.g. `format: byte` →
139    /// `Vec<u8>` + `base64_serde`); the generator wraps this in a
140    /// field-level `with = ...` attribute.
141    Primitive {
142        rust_type: String,
143        serde_with: Option<String>,
144    },
145    /// Object with properties
146    Object {
147        properties: BTreeMap<String, PropertyInfo>,
148        required: HashSet<String>,
149        additional_properties: ObjectAdditionalProperties,
150    },
151    /// Discriminated union (oneOf + discriminator)
152    DiscriminatedUnion {
153        discriminator_field: String,
154        variants: Vec<UnionVariant>,
155    },
156    /// Simple union (anyOf without discriminator)
157    Union { variants: Vec<SchemaRef> },
158    /// Array type
159    Array { item_type: Box<SchemaType> },
160    /// String enum
161    StringEnum { values: Vec<String> },
162    /// Extensible enum with known values and custom variant
163    ExtensibleEnum { known_values: Vec<String> },
164    /// Schema composition (allOf)
165    Composition { schemas: Vec<SchemaRef> },
166    /// Reference to another schema
167    Reference { target: String },
168}
169
170/// How an Object handles `additionalProperties`. Q2.3 split the
171/// pre-existing `bool` into a three-way enum so the generator can
172/// emit a typed `BTreeMap<String, T>` when the spec provides a
173/// value-type schema instead of degrading to `serde_json::Value`.
174#[derive(Debug, Clone)]
175pub enum ObjectAdditionalProperties {
176    /// `additionalProperties: false` or absent — extra keys are
177    /// rejected and no extra field is emitted.
178    Forbidden,
179    /// `additionalProperties: true` — extra keys captured as
180    /// `BTreeMap<String, serde_json::Value>`.
181    Untyped,
182    /// `additionalProperties: <schema>` — extra keys captured as
183    /// `BTreeMap<String, T>` where T comes from the schema.
184    Typed { value_type: Box<SchemaType> },
185}
186
187impl ObjectAdditionalProperties {
188    /// True when extra keys are accepted (regardless of typing).
189    /// Used by callers that only care whether the field exists.
190    pub fn is_open(&self) -> bool {
191        !matches!(self, Self::Forbidden)
192    }
193}
194
195#[derive(Debug, Clone)]
196pub struct PropertyInfo {
197    pub schema_type: SchemaType,
198    pub nullable: bool,
199    pub description: Option<String>,
200    pub default: Option<serde_json::Value>,
201    pub serde_attrs: Vec<String>,
202    /// Q2.4: OpenAPI constraint annotations captured from the
203    /// property schema. Surfaced by the generator as `/// Constraint:
204    /// …` doc lines and/or `#[validate(...)]` attributes depending on
205    /// `[generator.types.constraints] mode`.
206    pub constraints: PropertyConstraints,
207}
208
209/// Q2.4 — per-property OpenAPI constraint annotations
210/// (`minimum`/`maximum`/`minLength`/`maxLength`/`pattern`/etc.).
211/// Populated during analysis from `SchemaDetails`; consumed by the
212/// generator to emit doc comments and/or `#[validate(...)]` attrs.
213#[derive(Debug, Clone, Default)]
214pub struct PropertyConstraints {
215    pub minimum: Option<f64>,
216    pub maximum: Option<f64>,
217    pub exclusive_minimum: Option<f64>,
218    pub exclusive_maximum: Option<f64>,
219    pub multiple_of: Option<f64>,
220    pub min_length: Option<u64>,
221    pub max_length: Option<u64>,
222    pub pattern: Option<String>,
223    pub min_items: Option<u64>,
224    pub max_items: Option<u64>,
225    pub unique_items: Option<bool>,
226}
227
228impl PropertyConstraints {
229    pub fn is_empty(&self) -> bool {
230        self.minimum.is_none()
231            && self.maximum.is_none()
232            && self.exclusive_minimum.is_none()
233            && self.exclusive_maximum.is_none()
234            && self.multiple_of.is_none()
235            && self.min_length.is_none()
236            && self.max_length.is_none()
237            && self.pattern.is_none()
238            && self.min_items.is_none()
239            && self.max_items.is_none()
240            && self.unique_items.is_none()
241    }
242
243    /// Capture the constraint-related fields off a `SchemaDetails`.
244    /// Exclusive bounds in OpenAPI 3.1 are numeric (`exclusiveMinimum:
245    /// 5`); we map the OAS-3.0 boolean flag form by leaving the
246    /// exclusive field unset and letting `minimum`/`maximum` carry it.
247    pub fn from_schema_details(details: &crate::openapi::SchemaDetails) -> Self {
248        use crate::openapi::ExclusiveBound;
249        let exclusive_minimum = match &details.exclusive_minimum {
250            Some(ExclusiveBound::Number(v)) => Some(*v),
251            _ => None,
252        };
253        let exclusive_maximum = match &details.exclusive_maximum {
254            Some(ExclusiveBound::Number(v)) => Some(*v),
255            _ => None,
256        };
257        Self {
258            minimum: details.minimum,
259            maximum: details.maximum,
260            exclusive_minimum,
261            exclusive_maximum,
262            multiple_of: details.multiple_of,
263            min_length: details.min_length,
264            max_length: details.max_length,
265            pattern: details.pattern.clone(),
266            min_items: details.min_items,
267            max_items: details.max_items,
268            unique_items: details.unique_items,
269        }
270    }
271}
272
273#[derive(Debug, Clone)]
274pub struct UnionVariant {
275    pub rust_name: String,
276    pub type_name: String,
277    pub discriminator_value: String,
278    pub schema_ref: String,
279}
280
281#[derive(Debug, Clone)]
282pub struct SchemaRef {
283    pub target: String,
284    pub nullable: bool,
285}
286
287#[derive(Debug, Clone)]
288pub struct DependencyGraph {
289    pub edges: BTreeMap<String, HashSet<String>>,
290    /// Set of schemas that have recursive dependencies
291    pub recursive_schemas: HashSet<String>,
292}
293
294#[derive(Debug, Clone)]
295pub struct DetectedPatterns {
296    /// Schemas that should use tagged enums (discriminated unions)
297    pub tagged_enum_schemas: HashSet<String>,
298    /// Schemas that should use untagged enums (simple unions)  
299    pub untagged_enum_schemas: HashSet<String>,
300    /// Auto-detected type mappings for discriminated unions
301    pub type_mappings: BTreeMap<String, BTreeMap<String, String>>,
302}
303
304/// Information about an OpenAPI operation
305#[derive(Debug, Clone, Default, serde::Serialize)]
306pub struct OperationInfo {
307    /// Operation ID
308    pub operation_id: String,
309    /// HTTP method (GET, POST, etc.)
310    pub method: String,
311    /// Path template
312    pub path: String,
313    /// Short summary from OpenAPI spec
314    pub summary: Option<String>,
315    /// Longer description from OpenAPI spec
316    pub description: Option<String>,
317    /// Request body content type and schema (if any)
318    pub request_body: Option<RequestBodyContent>,
319    /// Whether `requestBody.required` was true. Drives whether the generated
320    /// method takes a `Body` argument or `Option<Body>` (T11).
321    pub request_body_required: bool,
322    /// Response schemas by status code
323    pub response_schemas: BTreeMap<String, String>,
324    /// Parameters (path, query, header)
325    pub parameters: Vec<ParameterInfo>,
326    /// Whether this operation supports streaming
327    pub supports_streaming: bool,
328    /// Stream parameter name if applicable
329    pub stream_parameter: Option<String>,
330    /// Tags declared on the operation. Empty when the spec sets none.
331    /// Used by the server codegen selector grammar (e.g. `tag:Chat`)
332    /// and by `openapi-to-rust server list` for grouping.
333    pub tags: Vec<String>,
334}
335
336/// Content type and schema for a request body
337#[derive(Debug, Clone, serde::Serialize)]
338#[serde(tag = "kind")]
339pub enum RequestBodyContent {
340    Json {
341        schema_name: String,
342        media_type: String,
343        #[serde(skip)]
344        validation_schema: Value,
345    },
346    FormUrlEncoded {
347        schema_name: String,
348        media_type: String,
349        #[serde(skip)]
350        validation_schema: Value,
351    },
352    Multipart,
353    OctetStream,
354    TextPlain,
355    /// A declared request media type without a schema. Client generation
356    /// preserves its historical no-body signature, while server generation
357    /// rejects the operation because there is no contract to validate.
358    SchemaLess {
359        media_type: String,
360    },
361    Unsupported {
362        media_types: Vec<String>,
363    },
364}
365
366impl RequestBodyContent {
367    /// Get the schema name if this content type has one
368    pub fn schema_name(&self) -> Option<&str> {
369        match self {
370            Self::Json { schema_name, .. } | Self::FormUrlEncoded { schema_name, .. } => {
371                Some(schema_name)
372            }
373            Self::Multipart
374            | Self::OctetStream
375            | Self::TextPlain
376            | Self::SchemaLess { .. }
377            | Self::Unsupported { .. } => None,
378        }
379    }
380}
381
382/// Compute the disambiguation-base for a parameter name. Mirrors
383/// `ClientGenerator::sanitize_param_name` so analysis-time uniqueness
384/// decisions and codegen-time emission agree on the final ident.
385fn base_param_ident(name: &str) -> String {
386    use heck::ToSnakeCase;
387    let suffix = if name.ends_with("<=") {
388        "_lte"
389    } else if name.ends_with(">=") {
390        "_gte"
391    } else if name.ends_with('<') {
392        "_lt"
393    } else if name.ends_with('>') {
394        "_gt"
395    } else {
396        ""
397    };
398    let stripped = name.trim_end_matches(['<', '>', '=']);
399    let mut snake = stripped.to_snake_case();
400    if snake.is_empty() {
401        snake.push_str("parameter");
402    } else if snake.starts_with(|character: char| character.is_ascii_digit()) {
403        snake.insert(0, '_');
404    }
405    snake.push_str(suffix);
406    snake
407}
408
409/// Information about an operation parameter
410#[derive(Debug, Clone, serde::Serialize)]
411pub struct ParameterInfo {
412    /// Parameter name
413    pub name: String,
414    /// Parameter location (path, query, header, cookie)
415    pub location: String,
416    /// Whether the parameter is required
417    pub required: bool,
418    /// Schema reference for the parameter type
419    pub schema_ref: Option<String>,
420    /// Rust type for this parameter
421    pub rust_type: String,
422    /// Description from OpenAPI spec
423    pub description: Option<String>,
424    /// String enum values when the parameter's inline schema is a string with
425    /// `enum` or `const`. When set, `rust_type` is the synthetic enum type
426    /// name (e.g. `GetItemTheConstant`) and the client generator emits an
427    /// inline enum so the parameter is constrained to the declared values.
428    /// See issue #10 follow-up.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub enum_values: Option<Vec<String>>,
431    /// Disambiguated Rust ident assigned by the analyzer at the operation
432    /// scope. When two parameters in the same operation sanitize to the same
433    /// snake_case name (e.g. `exclude_ids` + `exclude-ids` in vercel,
434    /// `StartTime` + `StartTime>` in twilio), the analyzer suffixes
435    /// later occurrences with `_2`, `_3`, … so the codegen function
436    /// signature and body don't reuse the same binding.
437    /// Empty/none = use sanitize from `name`.
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub rust_ident: Option<String>,
440    /// Wire serialization for object/array query parameters, decided from
441    /// the parameter's `style`/`explode` and schema shape (T14, GH #27).
442    /// `None` = plain single `name=value` pair (scalars, string enums, and
443    /// the ordinary scalar `name=value` representation. Unsupported complex
444    /// shapes carry an explicit [`QuerySerialization::Unsupported`] reason so
445    /// downstream client/server generators cannot silently drift.
446    /// For the object modes, `schema_ref` holds the struct type
447    /// generated/resolved for the object schema.
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub query_serialization: Option<QuerySerialization>,
450    /// Original parameter schema retained for request validation. This is not
451    /// exposed by serialized operation listings.
452    #[serde(skip)]
453    pub validation_schema: Option<Value>,
454}
455
456/// How generated clients serialize and generated servers extract an object-
457/// or array-schema query parameter.
458#[derive(Debug, Clone, PartialEq, serde::Serialize)]
459pub enum QuerySerialization {
460    /// style=form + explode=true object (the OAS 3.x defaults for query):
461    /// each property is its own pair — `?color=red&size=big`. The parameter
462    /// name never appears in the query string (RFC 6570 form-explosion).
463    FormExplodedObject,
464    /// style=form + explode=false object: one comma-joined key,value list —
465    /// `?filter=color,red,size,big`.
466    FormObject,
467    /// style=deepObject (explode=true) object: bracketed keys —
468    /// `?filter[color]=red`.
469    DeepObject,
470    /// style=form + explode=true array: repeated pairs — `?tags=a&tags=b`.
471    /// Parameter typed `Vec<item_type>`.
472    FormExplodedArray { item_type: ArrayItemType },
473    /// style=form + explode=false array: one comma-joined pair —
474    /// `?tags=a,b,c`. Parameter typed `Vec<item_type>`.
475    FormArray { item_type: ArrayItemType },
476    /// A complex query shape whose wire representation is undefined by
477    /// OpenAPI or not implemented symmetrically. Clients retain the explicit
478    /// opaque-string escape hatch; server generation rejects it with this
479    /// actionable reason instead of emitting an impossible extractor.
480    Unsupported { reason: String },
481}
482
483/// Item type of a typed array query parameter. The two variants need
484/// different handling in codegen: scalars are already Rust type strings
485/// (possibly paths like `rust_decimal::Decimal` from `[type_mappings]`),
486/// while enum refs are raw *schema names* that must run through
487/// `to_rust_type_name` sanitization (cloudflare:
488/// `resource-sharing_resource_type`).
489#[derive(Debug, Clone, PartialEq, serde::Serialize)]
490pub enum ArrayItemType {
491    /// A Rust scalar type string from the TypeMapper (`String`, `i32`, …).
492    Scalar(String),
493    /// The schema name of a referenced string enum (emits `Display`).
494    EnumRef(String),
495}
496
497impl Default for DependencyGraph {
498    fn default() -> Self {
499        Self::new()
500    }
501}
502
503impl DependencyGraph {
504    pub fn new() -> Self {
505        Self {
506            edges: BTreeMap::new(),
507            recursive_schemas: HashSet::new(),
508        }
509    }
510
511    pub fn add_dependency(&mut self, from: String, to: String) {
512        self.edges.entry(from).or_default().insert(to);
513    }
514
515    /// Get topological sort order for generation
516    pub fn topological_sort(&mut self) -> Result<Vec<String>> {
517        // First, detect and handle recursive dependencies
518        self.detect_recursive_schemas();
519
520        // Create a temporary graph without self-referencing edges for sorting
521        let mut temp_edges = self.edges.clone();
522        for (schema, deps) in &mut temp_edges {
523            deps.remove(schema); // Remove self-references
524        }
525
526        let mut visited = HashSet::new();
527        let mut temp_visited = HashSet::new();
528        let mut result = Vec::new();
529
530        // Visit all nodes using the temporary graph in sorted order for deterministic output
531        let mut all_nodes: Vec<_> = temp_edges.keys().collect();
532        all_nodes.sort();
533        for node in all_nodes {
534            if !visited.contains(node) {
535                self.visit_node_recursive(
536                    node,
537                    &temp_edges,
538                    &mut visited,
539                    &mut temp_visited,
540                    &mut result,
541                )?;
542            }
543        }
544
545        result.reverse();
546        Ok(result)
547    }
548
549    fn detect_recursive_schemas(&mut self) {
550        for (schema, deps) in &self.edges {
551            if deps.contains(schema) {
552                // Direct self-reference
553                self.recursive_schemas.insert(schema.clone());
554            } else {
555                // Check for indirect cycles
556                if self.has_cycle_from(schema, schema, &mut HashSet::new()) {
557                    self.recursive_schemas.insert(schema.clone());
558                }
559            }
560        }
561
562        // Also detect mutual recursion (like GraphNode <-> GraphEdge)
563        for (schema, deps) in &self.edges {
564            for dep in deps {
565                if let Some(dep_deps) = self.edges.get(dep) {
566                    if dep_deps.contains(schema) {
567                        // Mutual recursion detected
568                        self.recursive_schemas.insert(schema.clone());
569                        self.recursive_schemas.insert(dep.clone());
570                    }
571                }
572            }
573        }
574    }
575
576    fn has_cycle_from(&self, start: &str, current: &str, visited: &mut HashSet<String>) -> bool {
577        if visited.contains(current) {
578            return false; // Already checked this path
579        }
580
581        visited.insert(current.to_string());
582
583        if let Some(deps) = self.edges.get(current) {
584            for dep in deps {
585                if dep == start {
586                    return true; // Found cycle back to start
587                }
588                if self.has_cycle_from(start, dep, visited) {
589                    return true;
590                }
591            }
592        }
593
594        false
595    }
596
597    #[allow(clippy::only_used_in_recursion)]
598    fn visit_node_recursive(
599        &self,
600        node: &str,
601        temp_edges: &BTreeMap<String, HashSet<String>>,
602        visited: &mut HashSet<String>,
603        temp_visited: &mut HashSet<String>,
604        result: &mut Vec<String>,
605    ) -> Result<()> {
606        if temp_visited.contains(node) {
607            // This should not happen with cycle-free temp graph, but just in case
608            return Ok(());
609        }
610
611        if visited.contains(node) {
612            return Ok(());
613        }
614
615        temp_visited.insert(node.to_string());
616
617        if let Some(dependencies) = temp_edges.get(node) {
618            // Sort dependencies for deterministic topological order
619            let mut sorted_deps: Vec<_> = dependencies.iter().collect();
620            sorted_deps.sort();
621            for dep in sorted_deps {
622                self.visit_node_recursive(dep, temp_edges, visited, temp_visited, result)?;
623            }
624        }
625
626        temp_visited.remove(node);
627        visited.insert(node.to_string());
628        result.push(node.to_string());
629
630        Ok(())
631    }
632}
633
634/// Merge schema extension files into the main OpenAPI specification
635/// Uses simple recursive JSON object merging
636pub fn merge_schema_extensions(
637    main_spec: Value,
638    extension_paths: &[impl AsRef<Path>],
639) -> Result<Value> {
640    let mut result = main_spec;
641
642    for path in extension_paths {
643        let extension = load_extension_file(path.as_ref())?;
644        result = merge_json_objects_with_replacements(result, extension)?;
645    }
646
647    Ok(result)
648}
649
650/// Load an extension file and parse as JSON
651fn load_extension_file(path: &Path) -> Result<Value> {
652    let content = std::fs::read_to_string(path).map_err(|e| GeneratorError::FileError {
653        message: format!("Failed to read file {}: {}", path.display(), e),
654    })?;
655
656    serde_json::from_str(&content).map_err(GeneratorError::ParseError)
657}
658
659/// Merge JSON objects with explicit replacement support
660fn merge_json_objects_with_replacements(main: Value, extension: Value) -> Result<Value> {
661    // Extract replacement rules from the extension
662    let replacements = extract_replacement_rules(&extension);
663
664    // Perform the merge with replacement awareness
665    Ok(merge_json_objects_with_rules(
666        main,
667        extension,
668        &replacements,
669    ))
670}
671
672/// Extract x-replacements rules from extension
673fn extract_replacement_rules(
674    extension: &Value,
675) -> std::collections::HashMap<String, (String, String)> {
676    let mut rules = std::collections::HashMap::new();
677
678    if let Some(x_replacements) = extension.get("x-replacements") {
679        if let Some(x_replacements_obj) = x_replacements.as_object() {
680            for (schema_name, replacement_rule) in x_replacements_obj {
681                if let Some(rule_obj) = replacement_rule.as_object() {
682                    if let (Some(replace), Some(with)) = (
683                        rule_obj.get("replace").and_then(|v| v.as_str()),
684                        rule_obj.get("with").and_then(|v| v.as_str()),
685                    ) {
686                        rules.insert(schema_name.clone(), (replace.to_string(), with.to_string()));
687                        // println!("📋 Replacement rule: In {}, replace {} with {}", schema_name, replace, with);
688                    }
689                }
690            }
691        }
692    }
693
694    rules
695}
696
697/// Check if a variant should be replaced based on explicit replacement rules
698fn should_replace_variant(
699    schema_name: &str,
700    extension_refs: &[String],
701    replacements: &std::collections::HashMap<String, (String, String)>,
702) -> bool {
703    // Check all replacement rules
704    for (replace_schema, with_schema) in replacements.values() {
705        if schema_name == replace_schema {
706            // This schema should be replaced - check if the replacement schema is in extensions
707            let replacement_exists = extension_refs.iter().any(|ext_ref| {
708                let ext_schema_name = ext_ref.split('/').next_back().unwrap_or("");
709                ext_schema_name == with_schema
710            });
711
712            if replacement_exists {
713                return true;
714            }
715        }
716    }
717
718    // Fallback to exact name match for complete replacement
719    extension_refs.iter().any(|ext_ref| {
720        let ext_schema_name = ext_ref.split('/').next_back().unwrap_or("");
721        schema_name == ext_schema_name
722    })
723}
724
725/// Recursively merge two JSON values with replacement rules
726/// Objects are merged by combining properties
727/// Arrays are merged by concatenating
728/// Primitives in the extension override the main value
729fn merge_json_objects_with_rules(
730    main: Value,
731    extension: Value,
732    replacements: &std::collections::HashMap<String, (String, String)>,
733) -> Value {
734    match (main, extension) {
735        // Both objects - merge properties
736        (Value::Object(mut main_obj), Value::Object(ext_obj)) => {
737            // Special handling for schema objects with oneOf/anyOf variants.
738            // Detect which keyword the MAIN spec uses so we preserve it after merging.
739            let main_union_keyword = if main_obj.contains_key("oneOf") {
740                Some("oneOf")
741            } else if main_obj.contains_key("anyOf") {
742                Some("anyOf")
743            } else {
744                None
745            };
746            if let (Some(main_variants), Some(ext_variants)) = (
747                extract_schema_variants(&Value::Object(main_obj.clone())),
748                extract_schema_variants(&Value::Object(ext_obj.clone())),
749            ) {
750                let union_key = main_union_keyword.unwrap_or("oneOf");
751                println!(
752                    "🔍 Merging union schemas ({union_key}): {} main variants, {} extension variants",
753                    main_variants.len(),
754                    ext_variants.len()
755                );
756                // Merge the variant arrays, preserving the original union keyword
757                // First, collect main variants, but filter out any that will be replaced by extension
758                let mut merged_variants = Vec::new();
759                let extension_refs: Vec<String> = ext_variants
760                    .iter()
761                    .filter_map(|v| v.get("$ref").and_then(|r| r.as_str()))
762                    .map(|s| s.to_string())
763                    .collect();
764
765                // Add main variants that aren't being replaced
766                for main_variant in main_variants {
767                    if let Some(main_ref) = main_variant.get("$ref").and_then(|r| r.as_str()) {
768                        // Check if this main variant should be replaced by an extension variant
769                        let schema_name = main_ref.split('/').next_back().unwrap_or("");
770                        let should_replace =
771                            should_replace_variant(schema_name, &extension_refs, replacements);
772
773                        if should_replace {
774                            println!("🔄 REPLACING {} (explicit rule)", schema_name);
775                        }
776
777                        if !should_replace {
778                            merged_variants.push(main_variant);
779                        }
780                    } else {
781                        // Keep non-ref variants
782                        merged_variants.push(main_variant);
783                    }
784                }
785
786                // Add all extension variants
787                for ext_variant in ext_variants {
788                    merged_variants.push(ext_variant);
789                }
790
791                // Remove old oneOf/anyOf keys and add merged variants under the original keyword
792                main_obj.remove("oneOf");
793                main_obj.remove("anyOf");
794                main_obj.insert(union_key.to_string(), Value::Array(merged_variants));
795
796                // Merge other properties normally
797                for (key, ext_value) in ext_obj {
798                    if key != "oneOf" && key != "anyOf" {
799                        match main_obj.get(&key) {
800                            Some(main_value) => {
801                                let merged_value = merge_json_objects_with_rules(
802                                    main_value.clone(),
803                                    ext_value,
804                                    replacements,
805                                );
806                                main_obj.insert(key, merged_value);
807                            }
808                            None => {
809                                main_obj.insert(key, ext_value);
810                            }
811                        }
812                    }
813                }
814
815                return Value::Object(main_obj);
816            }
817
818            // Normal object merging
819            for (key, ext_value) in ext_obj {
820                match main_obj.get(&key) {
821                    Some(main_value) => {
822                        // Key exists in both - recursively merge
823                        let merged_value = merge_json_objects_with_rules(
824                            main_value.clone(),
825                            ext_value,
826                            replacements,
827                        );
828                        main_obj.insert(key, merged_value);
829                    }
830                    None => {
831                        // Key only in extension - add it
832                        main_obj.insert(key, ext_value);
833                    }
834                }
835            }
836            Value::Object(main_obj)
837        }
838
839        // Both arrays - concatenate
840        (Value::Array(mut main_arr), Value::Array(ext_arr)) => {
841            main_arr.extend(ext_arr);
842            Value::Array(main_arr)
843        }
844
845        // Extension overrides main for all other cases
846        (_, extension) => extension,
847    }
848}
849
850/// Extract schema variants from oneOf or anyOf properties
851fn extract_schema_variants(obj: &Value) -> Option<Vec<Value>> {
852    if let Value::Object(map) = obj {
853        if let Some(Value::Array(variants)) = map.get("oneOf") {
854            return Some(variants.clone());
855        }
856        if let Some(Value::Array(variants)) = map.get("anyOf") {
857            return Some(variants.clone());
858        }
859    }
860    None
861}
862
863pub struct SchemaAnalyzer {
864    schemas: BTreeMap<String, Schema>,
865    resolved_cache: BTreeMap<String, AnalyzedSchema>,
866    openapi_spec: Value,
867    current_schema_name: Option<String>,
868    component_parameters: BTreeMap<String, crate::openapi::Parameter>,
869    /// Single chokepoint for `(openapi_type, format)` → Rust-type
870    /// decisions (Q2.0). Defaulted when the analyzer is built without a
871    /// config; threaded from `GeneratorConfig.types` via
872    /// [`Self::with_type_mapper`].
873    type_mapper: TypeMapper,
874}
875
876impl SchemaAnalyzer {
877    /// Construct an analyzer with a default [`TypeMapper`]. Pre-Q2.0
878    /// callers (tests, simple bins) use this and get bit-identical
879    /// behavior to the pre-refactor code.
880    pub fn new(openapi_spec: Value) -> Result<Self> {
881        Self::with_type_mapper(openapi_spec, TypeMapper::default())
882    }
883
884    /// Construct an analyzer with a caller-supplied [`TypeMapper`]
885    /// (built from `GeneratorConfig.types`). The CLI / library entry
886    /// points use this so user TOML config drives type generation.
887    pub fn with_type_mapper(openapi_spec: Value, type_mapper: TypeMapper) -> Result<Self> {
888        let spec: OpenApiSpec =
889            serde_json::from_value(openapi_spec.clone()).map_err(GeneratorError::ParseError)?;
890        let schemas = Self::extract_schemas(&spec)?;
891
892        let component_parameters = spec
893            .components
894            .as_ref()
895            .and_then(|c| c.parameters.as_ref())
896            .cloned()
897            .unwrap_or_default();
898
899        Ok(Self {
900            schemas,
901            resolved_cache: BTreeMap::new(),
902            openapi_spec,
903            current_schema_name: None,
904            component_parameters,
905            type_mapper,
906        })
907    }
908
909    /// Create a new analyzer with schema extensions merged in (default
910    /// type mapper).
911    pub fn new_with_extensions(
912        openapi_spec: Value,
913        extension_paths: &[std::path::PathBuf],
914    ) -> Result<Self> {
915        let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
916        Self::new(merged_spec)
917    }
918
919    /// Same as [`Self::new_with_extensions`] but with a caller-supplied
920    /// type mapper.
921    pub fn new_with_extensions_and_type_mapper(
922        openapi_spec: Value,
923        extension_paths: &[std::path::PathBuf],
924        type_mapper: TypeMapper,
925    ) -> Result<Self> {
926        let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
927        Self::with_type_mapper(merged_spec, type_mapper)
928    }
929
930    /// Borrow the analyzer's type mapper. Useful for downstream
931    /// inspection (e.g. the dep advisory in Q2.8 reads
932    /// `type_mapper().used_features()` after generation).
933    pub fn type_mapper(&self) -> &TypeMapper {
934        &self.type_mapper
935    }
936
937    /// Generate a context-aware name for inline types, arrays, and variants
938    /// This provides better naming than generic names like UnionArray1, InlineVariant2, etc.
939    fn generate_context_aware_name(
940        &self,
941        base_context: &str,
942        type_hint: &str,
943        index: usize,
944        schema: Option<&Schema>,
945    ) -> String {
946        // First, try to infer a better name from the schema structure
947        if let Some(schema) = schema {
948            // For arrays, check if we can derive name from items
949            if type_hint == "Array"
950                && matches!(schema.schema_type(), Some(OpenApiSchemaType::Array))
951            {
952                if let Some(items_schema) = &schema.details().items {
953                    // Check for specific item types
954                    if let Some(item_type) = items_schema.schema_type() {
955                        match item_type {
956                            OpenApiSchemaType::Object => {
957                                return format!("{base_context}ItemArray");
958                            }
959                            OpenApiSchemaType::String => {
960                                return format!("{base_context}StringArray");
961                            }
962                            _ => {}
963                        }
964                    }
965                }
966            }
967        }
968
969        // Generate context-aware name based on type hint
970        match type_hint {
971            "Array" => {
972                // For arrays, always use context name instead of generic numbering
973                format!("{base_context}Array")
974            }
975            "Variant" | "InlineVariant" => {
976                // For variants, include index only if > 0 to keep first variant clean
977                if index == 0 {
978                    format!("{base_context}{type_hint}")
979                } else {
980                    format!("{}{}{}", base_context, type_hint, index + 1)
981                }
982            }
983            _ => {
984                // Default case
985                format!("{base_context}{type_hint}{index}")
986            }
987        }
988    }
989
990    /// Convert a string to PascalCase, handling underscores and hyphens
991    fn to_pascal_case(&self, s: &str) -> String {
992        s.split(['_', '-'])
993            .filter(|part| !part.is_empty())
994            .map(|part| {
995                let mut chars = part.chars();
996                match chars.next() {
997                    None => String::new(),
998                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
999                }
1000            })
1001            .collect()
1002    }
1003
1004    fn extract_schemas(spec: &OpenApiSpec) -> Result<BTreeMap<String, Schema>> {
1005        // OAS 3.1+ requires only one of `paths`, `webhooks`, or `components`.
1006        // A document may legitimately have no `components.schemas` (e.g. a
1007        // webhooks-only or paths-only spec). Return an empty map in that case
1008        // and let downstream codegen handle "no types to emit" gracefully.
1009        let schemas = spec.components.as_ref().and_then(|c| c.schemas.as_ref());
1010        Ok(schemas
1011            .map(|m| {
1012                m.iter()
1013                    .map(|(k, v)| (k.clone(), v.clone()))
1014                    .collect::<BTreeMap<_, _>>()
1015            })
1016            .unwrap_or_default())
1017    }
1018
1019    pub fn analyze(&mut self) -> Result<SchemaAnalysis> {
1020        let validation_context = ValidationContext {
1021            openapi_version: self
1022                .openapi_spec
1023                .get("openapi")
1024                .and_then(Value::as_str)
1025                .unwrap_or_default()
1026                .to_string(),
1027            json_schema_dialect: self
1028                .openapi_spec
1029                .get("jsonSchemaDialect")
1030                .and_then(Value::as_str)
1031                .map(str::to_string),
1032            component_schemas: self
1033                .openapi_spec
1034                .pointer("/components/schemas")
1035                .and_then(Value::as_object)
1036                .map(|schemas| {
1037                    schemas
1038                        .iter()
1039                        .map(|(name, schema)| (name.clone(), schema.clone()))
1040                        .collect()
1041                })
1042                .unwrap_or_default(),
1043        };
1044        let mut analysis = SchemaAnalysis {
1045            schemas: BTreeMap::new(),
1046            dependencies: DependencyGraph::new(),
1047            patterns: DetectedPatterns {
1048                tagged_enum_schemas: HashSet::new(),
1049                untagged_enum_schemas: HashSet::new(),
1050                type_mappings: BTreeMap::new(),
1051            },
1052            operations: BTreeMap::new(),
1053            operation_id_aliases: BTreeMap::new(),
1054            used_type_features: crate::type_mapping::UsedFeatures::default(),
1055            enum_extensions: BTreeMap::new(),
1056            validation_context,
1057        };
1058
1059        // First pass: detect patterns
1060        self.detect_patterns(&mut analysis.patterns)?;
1061
1062        // Second pass: analyze each schema
1063        let schema_names: Vec<String> = self.schemas.keys().cloned().collect();
1064        for schema_name in schema_names {
1065            let analyzed = self.analyze_schema(&schema_name)?;
1066
1067            // Build dependency graph
1068            for dep in &analyzed.dependencies {
1069                analysis
1070                    .dependencies
1071                    .add_dependency(schema_name.clone(), dep.clone());
1072            }
1073
1074            analysis.schemas.insert(schema_name, analyzed);
1075        }
1076
1077        // Third pass: include any inline schemas that were generated during analysis
1078        // BTreeMap maintains sorted order, so iteration is deterministic
1079        for (inline_name, inline_schema) in &self.resolved_cache {
1080            if !analysis.schemas.contains_key(inline_name) {
1081                // Add the inline schema first
1082                analysis
1083                    .schemas
1084                    .insert(inline_name.clone(), inline_schema.clone());
1085
1086                // Build dependency graph for inline schema's own dependencies
1087                for dep in &inline_schema.dependencies {
1088                    analysis
1089                        .dependencies
1090                        .add_dependency(inline_name.clone(), dep.clone());
1091                }
1092
1093                // Check if any existing schemas depend on this inline schema
1094                // We need to check ALL schemas, not just the ones already in analysis.schemas,
1095                // because parent schemas might have been analyzed but their dependencies
1096                // on inline schemas might not have been added to the dependency graph yet
1097                let mut schemas_to_update = Vec::new();
1098                for (schema_name, schema) in &analysis.schemas {
1099                    // Skip self-reference
1100                    if schema_name == inline_name {
1101                        continue;
1102                    }
1103
1104                    if schema.dependencies.contains(inline_name) {
1105                        // The parent schema depends on this inline schema
1106                        schemas_to_update.push(schema_name.clone());
1107                    }
1108                }
1109
1110                // Add the dependencies to the graph
1111                for schema_name in schemas_to_update {
1112                    analysis
1113                        .dependencies
1114                        .add_dependency(schema_name, inline_name.clone());
1115                }
1116            }
1117        }
1118
1119        // Fourth pass: analyze OpenAPI operations
1120        self.analyze_operations(&mut analysis)?;
1121
1122        // Fifth pass: include any inline schemas generated during operation analysis
1123        // (e.g., inline response types)
1124        for (inline_name, inline_schema) in &self.resolved_cache {
1125            if !analysis.schemas.contains_key(inline_name) {
1126                analysis
1127                    .schemas
1128                    .insert(inline_name.clone(), inline_schema.clone());
1129
1130                // Build dependency graph for inline schema's dependencies
1131                for dep in &inline_schema.dependencies {
1132                    analysis
1133                        .dependencies
1134                        .add_dependency(inline_name.clone(), dep.clone());
1135                }
1136            }
1137        }
1138
1139        // Snapshot the type-mapper's used-features set so the
1140        // generator can decide which helper modules to emit
1141        // (e.g. base64_serde for `format: byte`).
1142        analysis.used_type_features = self.type_mapper.used_features();
1143
1144        // Q2.6: capture x-enum-varnames / x-enum-descriptions from
1145        // each enum schema's original JSON. Side-channel keyed by
1146        // analyzed-schema name so we don't have to extend every
1147        // SchemaType::StringEnum constructor.
1148        for (name, analyzed) in &analysis.schemas {
1149            let enum_value_count = match &analyzed.schema_type {
1150                SchemaType::StringEnum { values } => values.len(),
1151                SchemaType::ExtensibleEnum { known_values } => known_values.len(),
1152                _ => continue,
1153            };
1154            if let Some(ext) = extract_enum_extensions(&analyzed.original, enum_value_count, name) {
1155                analysis.enum_extensions.insert(name.clone(), ext);
1156            }
1157        }
1158
1159        Ok(analysis)
1160    }
1161
1162    fn detect_patterns(&self, patterns: &mut DetectedPatterns) -> Result<()> {
1163        for (schema_name, schema) in &self.schemas {
1164            // Detect discriminated unions
1165            if self.is_discriminated_union(schema) {
1166                patterns.tagged_enum_schemas.insert(schema_name.clone());
1167
1168                // Extract type mappings for this union
1169                if let Some(mappings) = self.extract_type_mappings(schema)? {
1170                    patterns.type_mappings.insert(schema_name.clone(), mappings);
1171                }
1172            }
1173            // Detect simple unions
1174            else if self.is_simple_union(schema) {
1175                patterns.untagged_enum_schemas.insert(schema_name.clone());
1176            }
1177        }
1178
1179        Ok(())
1180    }
1181
1182    fn is_discriminated_union(&self, schema: &Schema) -> bool {
1183        // Check for explicit discriminator
1184        if schema.is_discriminated_union() {
1185            return true;
1186        }
1187
1188        // Auto-detect from union patterns with any common const field
1189        if let Some(variants) = schema.union_variants() {
1190            return variants.len() > 2 && self.detect_discriminator_field(variants).is_some();
1191        }
1192
1193        false
1194    }
1195
1196    fn all_variants_have_const_field(&self, variants: &[Schema], field_name: &str) -> bool {
1197        variants.iter().all(|variant| {
1198            if let Some(ref_str) = variant.reference() {
1199                // $ref variant: resolve and check the referenced schema
1200                if let Some(schema_name) = self.extract_schema_name(ref_str) {
1201                    if let Some(schema) = self.schemas.get(schema_name) {
1202                        return self.has_const_discriminator_field(schema, field_name);
1203                    }
1204                }
1205            } else {
1206                // Inline variant: check properties directly
1207                return self.has_const_discriminator_field(variant, field_name);
1208            }
1209            false
1210        })
1211    }
1212
1213    /// True when this branch of an anyOf/oneOf is (or resolves to) an
1214    /// object — the only kind of schema serde can deserialize via an
1215    /// internally-tagged enum. False for string/number/bool/array branches
1216    /// or refs to those, including string-enums.
1217    ///
1218    /// Used to detect the "hybrid string-or-object" union pattern (see bug
1219    /// openapi-generator-dpd) so we can downgrade those unions to
1220    /// `#[serde(untagged)]`.
1221    fn branch_resolves_to_object(&self, schema: &Schema) -> bool {
1222        // Follow $ref one hop, then ask the same question of the target.
1223        if let Some(ref_str) = schema.reference() {
1224            return match self
1225                .extract_schema_name(ref_str)
1226                .and_then(|n| self.schemas.get(n))
1227            {
1228                Some(target) => self.branch_resolves_to_object(target),
1229                None => false,
1230            };
1231        }
1232        // allOf compositions are object-shaped; same for anyOf/oneOf
1233        // wrappers (those will reduce to objects or to further unions).
1234        if matches!(
1235            schema,
1236            Schema::AllOf { .. } | Schema::AnyOf { .. } | Schema::OneOf { .. }
1237        ) {
1238            return true;
1239        }
1240        if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object)) {
1241            return true;
1242        }
1243        if schema.inferred_type() == Some(OpenApiSchemaType::Object) {
1244            return true;
1245        }
1246        // Anything else (string, integer, number, boolean, array, null,
1247        // string-enum, etc.) cannot carry a JSON tag field.
1248        false
1249    }
1250
1251    /// Scan all variants to find any common property that has a const/single-enum value
1252    /// across all variants. Returns the field name if found.
1253    /// Prioritizes "type" if it matches (most common convention).
1254    fn detect_discriminator_field(&self, variants: &[Schema]) -> Option<String> {
1255        if variants.is_empty() {
1256            return None;
1257        }
1258
1259        // Collect candidate field names from the first variant
1260        let first_variant = &variants[0];
1261        let first_schema = if let Some(ref_str) = first_variant.reference() {
1262            let schema_name = self.extract_schema_name(ref_str)?;
1263            self.schemas.get(schema_name)?
1264        } else {
1265            first_variant
1266        };
1267
1268        let properties = first_schema.details().properties.as_ref()?;
1269        let mut candidates: Vec<String> = Vec::new();
1270
1271        for (field_name, field_schema) in properties {
1272            let details = field_schema.details();
1273            let is_const = details.const_value.is_some()
1274                || details.enum_values.as_ref().is_some_and(|v| v.len() == 1)
1275                || details.extra.contains_key("const");
1276            if is_const {
1277                candidates.push(field_name.clone());
1278            }
1279        }
1280
1281        if candidates.is_empty() {
1282            return None;
1283        }
1284
1285        // Prioritize "type" if it's among candidates
1286        candidates.sort_by(|a, b| {
1287            if a == "type" {
1288                std::cmp::Ordering::Less
1289            } else if b == "type" {
1290                std::cmp::Ordering::Greater
1291            } else {
1292                a.cmp(b)
1293            }
1294        });
1295
1296        // Check each candidate against all variants
1297        for candidate in &candidates {
1298            if self.all_variants_have_const_field(variants, candidate) {
1299                return Some(candidate.clone());
1300            }
1301        }
1302
1303        None
1304    }
1305
1306    fn has_const_discriminator_field(&self, schema: &Schema, field_name: &str) -> bool {
1307        if let Some(properties) = &schema.details().properties {
1308            if let Some(field) = properties.get(field_name) {
1309                // Check for const value (OpenAPI 3.1 style)
1310                if field.details().const_value.is_some() {
1311                    return true;
1312                }
1313                // Check if it's an enum field with a single value
1314                if let Some(enum_vals) = &field.details().enum_values {
1315                    return enum_vals.len() == 1;
1316                }
1317                // Fallback: check extra fields for const
1318                return field.details().extra.contains_key("const");
1319            }
1320        }
1321        false
1322    }
1323
1324    fn is_simple_union(&self, schema: &Schema) -> bool {
1325        if let Some(variants) = schema.union_variants() {
1326            // Simple union: multiple types but not nullable pattern
1327            if variants.len() > 1 && !schema.is_nullable_pattern() {
1328                let has_refs = variants.iter().any(|v| v.is_reference());
1329                return has_refs;
1330            }
1331        }
1332        false
1333    }
1334
1335    fn extract_type_mappings(&self, schema: &Schema) -> Result<Option<BTreeMap<String, String>>> {
1336        let variants = schema.union_variants().ok_or_else(|| {
1337            GeneratorError::InvalidSchema("No variants found for discriminated union".to_string())
1338        })?;
1339
1340        // Get the discriminator field name from the schema
1341        let discriminator_field = if let Some(discriminator) = schema.discriminator() {
1342            discriminator.property_name.clone()
1343        } else if let Some(detected) = self.detect_discriminator_field(variants) {
1344            detected
1345        } else {
1346            "type".to_string() // fallback to "type" for auto-detected discriminated unions
1347        };
1348
1349        let mut mappings = BTreeMap::new();
1350
1351        for variant in variants {
1352            if let Some(ref_str) = variant.reference() {
1353                if let Some(type_name) = self.extract_schema_name(ref_str) {
1354                    if let Some(variant_schema) = self.schemas.get(type_name) {
1355                        if let Some(discriminator_value) = self
1356                            .extract_discriminator_value_for_field(
1357                                variant_schema,
1358                                &discriminator_field,
1359                            )
1360                        {
1361                            mappings.insert(type_name.to_string(), discriminator_value);
1362                        }
1363                    }
1364                }
1365            }
1366        }
1367
1368        if mappings.is_empty() {
1369            Ok(None)
1370        } else {
1371            Ok(Some(mappings))
1372        }
1373    }
1374
1375    #[allow(dead_code)]
1376    fn extract_discriminator_value(&self, schema: &Schema) -> Option<String> {
1377        self.extract_discriminator_value_for_field(schema, "type")
1378    }
1379
1380    fn extract_discriminator_value_for_field(
1381        &self,
1382        schema: &Schema,
1383        field_name: &str,
1384    ) -> Option<String> {
1385        if let Some(properties) = &schema.details().properties {
1386            if let Some(type_field) = properties.get(field_name) {
1387                // Check for const value first (highest priority)
1388                if let Some(const_value) = &type_field.details().const_value {
1389                    if let Some(value) = const_value.as_str() {
1390                        return Some(value.to_string());
1391                    }
1392                }
1393                // Check for enum with single value
1394                if let Some(enum_values) = &type_field.details().enum_values {
1395                    if enum_values.len() == 1 {
1396                        return enum_values[0].as_str().map(|s| s.to_string());
1397                    }
1398                }
1399                // Check for const value in extra fields
1400                if let Some(const_value) = type_field.details().extra.get("const") {
1401                    return const_value.as_str().map(|s| s.to_string());
1402                }
1403                // Check for x-stainless-const with default value
1404                if let Some(stainless_const) = type_field.details().extra.get("x-stainless-const") {
1405                    if stainless_const.as_bool() == Some(true) {
1406                        if let Some(default_value) = &type_field.details().default {
1407                            if let Some(value) = default_value.as_str() {
1408                                return Some(value.to_string());
1409                            }
1410                        }
1411                    }
1412                }
1413            }
1414        }
1415        None
1416    }
1417
1418    fn get_any_reference<'a>(&self, schema: &'a Schema) -> Option<&'a str> {
1419        schema.reference().or_else(|| schema.recursive_reference())
1420    }
1421
1422    fn extract_schema_name<'a>(&self, ref_str: &'a str) -> Option<&'a str> {
1423        if ref_str == "#" {
1424            return None; // Special case for self-reference
1425        }
1426
1427        let parts: Vec<&str> = ref_str.split('/').collect();
1428
1429        // Standard 3.x pattern: #/components/schemas/{SchemaName}[/deeper/path]
1430        if parts.len() >= 4 && parts[0] == "#" && parts[2] == "schemas" {
1431            return Some(parts[3]);
1432        }
1433
1434        // Swagger 2.0 carry-over: some 3.x specs (Google) still use
1435        // `#/definitions/{SchemaName}`. Treat it as an alias.
1436        if parts.len() >= 3 && parts[0] == "#" && parts[1] == "definitions" {
1437            return Some(parts[2]);
1438        }
1439
1440        // Last-segment fallback for other ref shapes — but only if the
1441        // segment plausibly names a top-level schema (PascalCase, no digits-
1442        // only, not a JSON-schema keyword like `schema`/`properties`/`items`).
1443        // pagerduty has `#/components/parameters/foo/schema`, where the last
1444        // segment "schema" is a sub-path indicator, not a schema name.
1445        let last = parts.last()?;
1446        if last.is_empty()
1447            || last.chars().all(|c| c.is_ascii_digit())
1448            || matches!(
1449                *last,
1450                "schema" | "properties" | "items" | "additionalProperties"
1451            )
1452        {
1453            return None;
1454        }
1455        let first = last.chars().next().unwrap_or(' ');
1456        if !first.is_ascii_alphabetic() || !first.is_ascii_uppercase() {
1457            return None;
1458        }
1459        Some(last)
1460    }
1461
1462    fn analyze_schema(&mut self, schema_name: &str) -> Result<AnalyzedSchema> {
1463        // Check cache first
1464        if let Some(cached) = self.resolved_cache.get(schema_name) {
1465            return Ok(cached.clone());
1466        }
1467
1468        // Set current schema name for context
1469        self.current_schema_name = Some(schema_name.to_string());
1470
1471        let schema = self
1472            .schemas
1473            .get(schema_name)
1474            .ok_or_else(|| GeneratorError::UnresolvedReference(schema_name.to_string()))?
1475            .clone();
1476
1477        // Prevent infinite recursion with placeholder
1478        self.resolved_cache.insert(
1479            schema_name.to_string(),
1480            AnalyzedSchema {
1481                name: schema_name.to_string(),
1482                original: serde_json::to_value(&schema).unwrap_or(Value::Null),
1483                schema_type: SchemaType::Reference {
1484                    target: "placeholder".to_string(),
1485                },
1486                dependencies: HashSet::new(),
1487                nullable: false,
1488                description: None,
1489                default: None,
1490            },
1491        );
1492
1493        let analyzed = self.analyze_schema_value(&schema, schema_name)?;
1494
1495        // Update cache with real result
1496        self.resolved_cache
1497            .insert(schema_name.to_string(), analyzed.clone());
1498
1499        Ok(analyzed)
1500    }
1501
1502    fn analyze_schema_value(
1503        &mut self,
1504        schema: &Schema,
1505        schema_name: &str,
1506    ) -> Result<AnalyzedSchema> {
1507        let details = schema.details();
1508        let description = details.description.clone();
1509        // Combine 3.0-style `nullable: true` with 3.1's `type: ["X", "null"]`.
1510        let nullable = details.is_nullable() || schema.type_array_contains_null();
1511        let mut dependencies = HashSet::new();
1512
1513        let schema_type = match schema {
1514            Schema::Reference { reference, .. } => {
1515                // For real-world refs we can't resolve to a known schema name
1516                // (e.g. pagerduty's `#/components/parameters/foo/schema`),
1517                // fall back to opaque JSON instead of failing whole-document
1518                // generation. The rest of the spec is usually unaffected.
1519                match self.extract_schema_name(reference) {
1520                    Some(name) => {
1521                        let target = name.to_string();
1522                        dependencies.insert(target.clone());
1523                        SchemaType::Reference { target }
1524                    }
1525                    None => {
1526                        eprintln!(
1527                            "⚠️  unresolvable $ref `{}` — typing as serde_json::Value",
1528                            reference
1529                        );
1530                        SchemaType::Primitive {
1531                            rust_type: "serde_json::Value".to_string(),
1532                            serde_with: None,
1533                        }
1534                    }
1535                }
1536            }
1537            Schema::RecursiveRef { recursive_ref, .. }
1538            | Schema::DynamicRef {
1539                dynamic_ref: recursive_ref,
1540                ..
1541            } => {
1542                // Handle recursive / dynamic references. J1: full $dynamicRef
1543                // resolution against $dynamicAnchor scopes is a follow-up; for
1544                // now we treat them like recursive refs (self-reference when
1545                // it's a fragment to the same schema, otherwise resolve via
1546                // schema name).
1547                if recursive_ref == "#" {
1548                    dependencies.insert(schema_name.to_string());
1549                    SchemaType::Reference {
1550                        target: schema_name.to_string(),
1551                    }
1552                } else {
1553                    let target = self
1554                        .extract_schema_name(recursive_ref)
1555                        .unwrap_or(schema_name)
1556                        .to_string();
1557                    dependencies.insert(target.clone());
1558                    SchemaType::Reference { target }
1559                }
1560            }
1561            Schema::Typed { .. } | Schema::TypedMulti { .. } => {
1562                let primary = schema
1563                    .schema_type()
1564                    .cloned()
1565                    .unwrap_or(OpenApiSchemaType::Object);
1566                let format = details.format.as_deref();
1567                match primary {
1568                    OpenApiSchemaType::String => {
1569                        if let Some(values) = details.string_enum_values() {
1570                            SchemaType::StringEnum { values }
1571                        } else {
1572                            SchemaType::Primitive {
1573                                rust_type: self.type_mapper.string_format(format).rust_type,
1574                                serde_with: None,
1575                            }
1576                        }
1577                    }
1578                    OpenApiSchemaType::Integer => SchemaType::Primitive {
1579                        rust_type: self.type_mapper.integer_format(format).rust_type,
1580                        serde_with: None,
1581                    },
1582                    OpenApiSchemaType::Number => SchemaType::Primitive {
1583                        rust_type: self.type_mapper.number_format(format).rust_type,
1584                        serde_with: None,
1585                    },
1586                    OpenApiSchemaType::Boolean => SchemaType::Primitive {
1587                        rust_type: self.type_mapper.boolean().rust_type,
1588                        serde_with: None,
1589                    },
1590                    OpenApiSchemaType::Array => {
1591                        // Analyze array item type
1592                        self.analyze_array_schema(schema, schema_name, &mut dependencies)?
1593                    }
1594                    OpenApiSchemaType::Object => {
1595                        // Check if this is a dynamic JSON object
1596                        if self.should_use_dynamic_json(schema) {
1597                            SchemaType::Primitive {
1598                                rust_type: self.type_mapper.dynamic_json().rust_type,
1599                                serde_with: None,
1600                            }
1601                        } else {
1602                            // Analyze object properties
1603                            self.analyze_object_schema(schema, &mut dependencies)?
1604                        }
1605                    }
1606                    _ => SchemaType::Primitive {
1607                        rust_type: self.type_mapper.dynamic_json().rust_type,
1608                        serde_with: None,
1609                    },
1610                }
1611            }
1612            Schema::AnyOf {
1613                any_of,
1614                discriminator,
1615                ..
1616            } => {
1617                // Handle anyOf patterns (nullable vs flexible union vs discriminated)
1618                self.analyze_anyof_union(
1619                    any_of,
1620                    discriminator.as_ref(),
1621                    &mut dependencies,
1622                    schema_name,
1623                )?
1624            }
1625            Schema::OneOf {
1626                one_of,
1627                discriminator,
1628                ..
1629            } => {
1630                // Handle oneOf discriminated unions
1631                self.analyze_oneof_union(
1632                    one_of,
1633                    discriminator.as_ref(),
1634                    schema_name,
1635                    &mut dependencies,
1636                )?
1637            }
1638            Schema::AllOf { all_of, .. } => {
1639                // Handle allOf composition (schema inheritance)
1640                self.analyze_allof_composition(all_of, &mut dependencies)?
1641            }
1642            Schema::Untyped { .. } => {
1643                // Try to infer type from structure
1644                if let Some(inferred) = schema.inferred_type() {
1645                    match inferred {
1646                        OpenApiSchemaType::Object => {
1647                            if self.should_use_dynamic_json(schema) {
1648                                SchemaType::Primitive {
1649                                    rust_type: "serde_json::Value".to_string(),
1650                                    serde_with: None,
1651                                }
1652                            } else {
1653                                self.analyze_object_schema(schema, &mut dependencies)?
1654                            }
1655                        }
1656                        OpenApiSchemaType::String if details.is_string_enum() => {
1657                            SchemaType::StringEnum {
1658                                values: details.string_enum_values().unwrap_or_default(),
1659                            }
1660                        }
1661                        _ => SchemaType::Primitive {
1662                            rust_type: "serde_json::Value".to_string(),
1663                            serde_with: None,
1664                        },
1665                    }
1666                } else {
1667                    SchemaType::Primitive {
1668                        rust_type: "serde_json::Value".to_string(),
1669                        serde_with: None,
1670                    }
1671                }
1672            }
1673        };
1674
1675        Ok(AnalyzedSchema {
1676            name: schema_name.to_string(),
1677            original: serde_json::to_value(schema).unwrap_or(Value::Null), // Convert back to Value for now
1678            schema_type,
1679            dependencies,
1680            nullable,
1681            description,
1682            default: details.default.clone(),
1683        })
1684    }
1685
1686    fn analyze_object_schema(
1687        &mut self,
1688        schema: &Schema,
1689        dependencies: &mut HashSet<String>,
1690    ) -> Result<SchemaType> {
1691        let details = schema.details();
1692        let properties = &details.properties;
1693        let required = details
1694            .required
1695            .as_ref()
1696            .map(|req| req.iter().cloned().collect::<HashSet<String>>())
1697            .unwrap_or_default();
1698
1699        let mut property_info = BTreeMap::new();
1700
1701        if let Some(props) = properties {
1702            for (prop_name, prop_schema) in props {
1703                // Check if this property is a union that needs a named type
1704                let prop_type = if let Schema::AnyOf { any_of, .. } = prop_schema {
1705                    // First check if this should be a dynamic JSON pattern
1706                    if self.should_use_dynamic_json(prop_schema) {
1707                        // This is a dynamic JSON pattern, use serde_json::Value directly
1708                        SchemaType::Primitive {
1709                            rust_type: "serde_json::Value".to_string(),
1710                            serde_with: None,
1711                        }
1712                    } else if prop_schema.is_nullable_pattern()
1713                        && let Some(non_null) = prop_schema.non_null_variant()
1714                    {
1715                        // 3.1 idiom: `anyOf: [<schema>, {type: null}]`. The
1716                        // wrapper has no semantic value beyond nullability;
1717                        // unwrap to the inner type. Without this, the synthesized
1718                        // wrapper type collides with the inner $ref's name when
1719                        // the property name produces a colliding parent context
1720                        // (e.g. `Step.status` → `StepStatus`, which is also the
1721                        // referenced component).
1722                        self.analyze_property_schema_with_context(
1723                            non_null,
1724                            Some(prop_name),
1725                            dependencies,
1726                        )?
1727                    } else {
1728                        // This is an anyOf union in a property - create a named union type
1729                        // Use the current schema name as context to make the union name unique
1730                        let context_name = self
1731                            .current_schema_name
1732                            .clone()
1733                            .unwrap_or_else(|| "Unknown".to_string());
1734
1735                        // Generate a name based on both the schema and property name
1736                        let prop_pascal = self.to_pascal_case(prop_name);
1737                        let mut union_type_name = format!("{context_name}{prop_pascal}");
1738
1739                        // Avoid colliding with an existing component schema or
1740                        // an inline name that's already in resolved_cache.
1741                        if self.schemas.contains_key(&union_type_name)
1742                            || self.resolved_cache.contains_key(&union_type_name)
1743                        {
1744                            let mut suffix = 2;
1745                            loop {
1746                                let candidate = format!("{union_type_name}Union{suffix}");
1747                                if !self.schemas.contains_key(&candidate)
1748                                    && !self.resolved_cache.contains_key(&candidate)
1749                                {
1750                                    union_type_name = candidate;
1751                                    break;
1752                                }
1753                                suffix += 1;
1754                                if suffix > 1000 {
1755                                    break;
1756                                }
1757                            }
1758                        }
1759
1760                        // Analyze the union
1761                        let union_schema_type = self.analyze_anyof_union(
1762                            any_of,
1763                            prop_schema.discriminator(),
1764                            dependencies,
1765                            &union_type_name,
1766                        )?;
1767
1768                        // Store the union as a named schema
1769                        self.resolved_cache.insert(
1770                            union_type_name.clone(),
1771                            AnalyzedSchema {
1772                                name: union_type_name.clone(),
1773                                original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
1774                                schema_type: union_schema_type,
1775                                dependencies: HashSet::new(),
1776                                nullable: false,
1777                                description: prop_schema.details().description.clone(),
1778                                default: None,
1779                            },
1780                        );
1781
1782                        // Return a reference to the named union type
1783                        dependencies.insert(union_type_name.clone());
1784                        SchemaType::Reference {
1785                            target: union_type_name,
1786                        }
1787                    }
1788                } else if let Schema::OneOf {
1789                    one_of,
1790                    discriminator,
1791                    ..
1792                } = prop_schema
1793                {
1794                    // 3.1 idiom: `oneOf: [<schema>, {type: null}]`. Same
1795                    // unwrap as anyOf above — without this, the synthesized
1796                    // wrapper type collides with the inner $ref's name
1797                    // (discord's `QuarantineUserAction.metadata` →
1798                    // `QuarantineUserActionMetadata` clashing with the
1799                    // referenced `QuarantineUserActionMetadata` schema).
1800                    if prop_schema.is_nullable_pattern()
1801                        && let Some(non_null) = prop_schema.non_null_variant()
1802                    {
1803                        let unwrapped = self.analyze_property_schema_with_context(
1804                            non_null,
1805                            Some(prop_name),
1806                            dependencies,
1807                        )?;
1808                        let prop_details = prop_schema.details();
1809                        let prop_nullable = true;
1810                        let prop_description = prop_details.description.clone();
1811                        let prop_default = prop_details.default.clone();
1812                        property_info.insert(
1813                            prop_name.clone(),
1814                            PropertyInfo {
1815                                schema_type: unwrapped,
1816                                nullable: prop_nullable,
1817                                description: prop_description,
1818                                default: prop_default,
1819                                serde_attrs: Vec::new(),
1820                                constraints: PropertyConstraints::from_schema_details(prop_details),
1821                            },
1822                        );
1823                        continue;
1824                    }
1825
1826                    // Handle oneOf discriminated unions in properties
1827                    let context_name = self
1828                        .current_schema_name
1829                        .clone()
1830                        .unwrap_or_else(|| "Unknown".to_string());
1831                    let prop_pascal = self.to_pascal_case(prop_name);
1832                    let mut union_type_name = format!("{context_name}{prop_pascal}");
1833                    // Same collision-suffix dance as the anyOf branch above.
1834                    if self.schemas.contains_key(&union_type_name)
1835                        || self.resolved_cache.contains_key(&union_type_name)
1836                    {
1837                        let mut suffix = 2;
1838                        loop {
1839                            let candidate = format!("{union_type_name}Union{suffix}");
1840                            if !self.schemas.contains_key(&candidate)
1841                                && !self.resolved_cache.contains_key(&candidate)
1842                            {
1843                                union_type_name = candidate;
1844                                break;
1845                            }
1846                            suffix += 1;
1847                            if suffix > 1000 {
1848                                break;
1849                            }
1850                        }
1851                    }
1852
1853                    // Analyze the discriminated union
1854                    let union_schema_type = self.analyze_oneof_union(
1855                        one_of,
1856                        discriminator.as_ref(),
1857                        &union_type_name,
1858                        dependencies,
1859                    )?;
1860
1861                    // Store the union as a named schema
1862                    self.resolved_cache.insert(
1863                        union_type_name.clone(),
1864                        AnalyzedSchema {
1865                            name: union_type_name.clone(),
1866                            original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
1867                            schema_type: union_schema_type,
1868                            dependencies: HashSet::new(),
1869                            nullable: false,
1870                            description: prop_schema.details().description.clone(),
1871                            default: None,
1872                        },
1873                    );
1874
1875                    // Return a reference to the named union type
1876                    dependencies.insert(union_type_name.clone());
1877                    SchemaType::Reference {
1878                        target: union_type_name,
1879                    }
1880                } else {
1881                    // Regular property schema analysis - pass property name for context
1882                    self.analyze_property_schema_with_context(
1883                        prop_schema,
1884                        Some(prop_name),
1885                        dependencies,
1886                    )?
1887                };
1888
1889                let prop_details = prop_schema.details();
1890                // Check for both explicit nullable and anyOf nullable patterns
1891                let prop_nullable = prop_details.is_nullable() || prop_schema.is_nullable_pattern();
1892                let prop_description = prop_details.description.clone();
1893                let prop_default = prop_details.default.clone();
1894
1895                property_info.insert(
1896                    prop_name.clone(),
1897                    PropertyInfo {
1898                        schema_type: prop_type,
1899                        nullable: prop_nullable,
1900                        description: prop_description,
1901                        default: prop_default,
1902                        serde_attrs: Vec::new(),
1903                        constraints: PropertyConstraints::from_schema_details(prop_details),
1904                    },
1905                );
1906            }
1907        }
1908
1909        // Q2.3: classify additionalProperties three ways. When the
1910        // spec gives us a schema we analyze it and emit a typed
1911        // BTreeMap<String, T>; pre-Q2.3 collapsed both Schema and
1912        // Boolean(true) to the same untyped map. Toggle:
1913        //   [generator.types.shape] additional_properties_typed
1914        // Default true; setting false reverts the schema case to
1915        // Untyped (current pre-Q2.3 behavior).
1916        let typed_enabled = self
1917            .type_mapper
1918            .config()
1919            .shape
1920            .as_ref()
1921            .and_then(|s| s.additional_properties_typed)
1922            .unwrap_or(true);
1923
1924        let additional_properties = match &details.additional_properties {
1925            Some(crate::openapi::AdditionalProperties::Boolean(true)) => {
1926                ObjectAdditionalProperties::Untyped
1927            }
1928            Some(crate::openapi::AdditionalProperties::Boolean(false)) => {
1929                ObjectAdditionalProperties::Forbidden
1930            }
1931            Some(crate::openapi::AdditionalProperties::Schema(value_schema)) if typed_enabled => {
1932                let analyzed =
1933                    self.analyze_property_schema_with_context(value_schema, None, dependencies)?;
1934                ObjectAdditionalProperties::Typed {
1935                    value_type: Box::new(analyzed),
1936                }
1937            }
1938            Some(crate::openapi::AdditionalProperties::Schema(_)) => {
1939                // typed_enabled = false: degrade to the pre-Q2.3 behavior.
1940                ObjectAdditionalProperties::Untyped
1941            }
1942            None => ObjectAdditionalProperties::Forbidden,
1943        };
1944
1945        Ok(SchemaType::Object {
1946            properties: property_info,
1947            required,
1948            additional_properties,
1949        })
1950    }
1951
1952    fn analyze_property_schema_with_context(
1953        &mut self,
1954        schema: &Schema,
1955        property_name: Option<&str>,
1956        dependencies: &mut HashSet<String>,
1957    ) -> Result<SchemaType> {
1958        if let Some(ref_str) = self.get_any_reference(schema) {
1959            let target_opt = if ref_str == "#" {
1960                Some(
1961                    self.find_recursive_anchor_schema()
1962                        .unwrap_or_else(|| "UnknownRecursive".to_string()),
1963                )
1964            } else {
1965                self.extract_schema_name(ref_str).map(|s| s.to_string())
1966            };
1967            match target_opt {
1968                Some(target) => {
1969                    dependencies.insert(target.clone());
1970                    return Ok(SchemaType::Reference { target });
1971                }
1972                None => {
1973                    eprintln!(
1974                        "⚠️  unresolvable $ref `{}` — typing as serde_json::Value",
1975                        ref_str
1976                    );
1977                    return Ok(SchemaType::Primitive {
1978                        rust_type: "serde_json::Value".to_string(),
1979                        serde_with: None,
1980                    });
1981                }
1982            }
1983        }
1984
1985        if let Some(schema_type) = schema.schema_type() {
1986            match schema_type {
1987                OpenApiSchemaType::String => {
1988                    // Check if this string type has enum values
1989                    if let Some(enum_values) = schema.details().string_enum_values() {
1990                        // This is an inline enum in a property - create a named enum type
1991                        // Use the current schema name as context to make the enum name unique
1992                        let context_name = self
1993                            .current_schema_name
1994                            .clone()
1995                            .unwrap_or_else(|| "Unknown".to_string());
1996
1997                        // Generate a candidate name based on both the schema and property context.
1998                        let primary_name = if let Some(prop_name) = property_name {
1999                            // We have property name context - use it for a unique name
2000                            let prop_pascal = self.to_pascal_case(prop_name);
2001                            format!("{context_name}{prop_pascal}")
2002                        } else {
2003                            // No property name context - generate a unique name using enum values
2004                            // Use the first enum value to help make the name unique
2005                            let suffix = if !enum_values.is_empty() {
2006                                let first_value = self.to_pascal_case(&enum_values[0]);
2007                                format!("{first_value}Enum")
2008                            } else {
2009                                "StringEnum".to_string()
2010                            };
2011                            format!("{context_name}{suffix}")
2012                        };
2013
2014                        return Ok(self.hoist_inline_string_enum(
2015                            schema,
2016                            enum_values,
2017                            primary_name,
2018                            dependencies,
2019                        ));
2020                    } else {
2021                        // Property-level string with no enum values:
2022                        // route through TypeMapper so `format: date-time`
2023                        // / `uuid` / etc. surface as typed scalars
2024                        // (chrono::DateTime, uuid::Uuid, …) instead of
2025                        // collapsing to bare `String`.
2026                        let mapped = self
2027                            .type_mapper
2028                            .string_format(schema.details().format.as_deref());
2029                        return Ok(SchemaType::Primitive {
2030                            rust_type: mapped.rust_type,
2031                            serde_with: mapped.serde_with,
2032                        });
2033                    }
2034                }
2035                OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
2036                    let details = schema.details();
2037                    let rust_type = self.get_number_rust_type(schema_type.clone(), details);
2038                    return Ok(SchemaType::Primitive {
2039                        rust_type,
2040                        serde_with: None,
2041                    });
2042                }
2043                OpenApiSchemaType::Boolean => {
2044                    return Ok(SchemaType::Primitive {
2045                        rust_type: "bool".to_string(),
2046                        serde_with: None,
2047                    });
2048                }
2049                OpenApiSchemaType::Array => {
2050                    // Analyze array property with context
2051                    let context_name = if let Some(prop_name) = property_name {
2052                        // Use property name for context
2053                        let prop_pascal = self.to_pascal_case(prop_name);
2054                        format!(
2055                            "{}{}",
2056                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2057                            prop_pascal
2058                        )
2059                    } else {
2060                        // Fallback to generic name
2061                        "ArrayItem".to_string()
2062                    };
2063                    return self.analyze_array_schema(schema, &context_name, dependencies);
2064                }
2065                OpenApiSchemaType::Object => {
2066                    // Check if this is a dynamic JSON object
2067                    if self.should_use_dynamic_json(schema) {
2068                        return Ok(SchemaType::Primitive {
2069                            rust_type: "serde_json::Value".to_string(),
2070                            serde_with: None,
2071                        });
2072                    }
2073                    // Inline object in property - create a named schema for it
2074                    let object_type_name = if let Some(prop_name) = property_name {
2075                        // Use property name for context
2076                        let prop_pascal = self.to_pascal_case(prop_name);
2077                        format!(
2078                            "{}{}",
2079                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2080                            prop_pascal
2081                        )
2082                    } else {
2083                        // Fallback to generic name
2084                        format!(
2085                            "{}Object",
2086                            self.current_schema_name.as_deref().unwrap_or("Unknown")
2087                        )
2088                    };
2089
2090                    // Analyze the object schema
2091                    let object_type = self.analyze_object_schema(schema, dependencies)?;
2092
2093                    // Create an analyzed schema for the inline object
2094                    let inline_schema = AnalyzedSchema {
2095                        name: object_type_name.clone(),
2096                        original: serde_json::to_value(schema).unwrap_or(Value::Null),
2097                        schema_type: object_type,
2098                        dependencies: dependencies.clone(),
2099                        nullable: false,
2100                        description: schema.details().description.clone(),
2101                        default: None,
2102                    };
2103
2104                    // Add the inline object as a named schema
2105                    self.resolved_cache
2106                        .insert(object_type_name.clone(), inline_schema);
2107                    dependencies.insert(object_type_name.clone());
2108
2109                    // Return a reference to the named schema
2110                    return Ok(SchemaType::Reference {
2111                        target: object_type_name,
2112                    });
2113                }
2114                _ => {
2115                    return Ok(SchemaType::Primitive {
2116                        rust_type: "serde_json::Value".to_string(),
2117                        serde_with: None,
2118                    });
2119                }
2120            }
2121        }
2122
2123        // Handle nullable patterns
2124        if schema.is_nullable_pattern() {
2125            if let Some(non_null) = schema.non_null_variant() {
2126                return self.analyze_property_schema_with_context(
2127                    non_null,
2128                    property_name,
2129                    dependencies,
2130                );
2131            }
2132        }
2133
2134        // Check if this should be dynamic JSON before further analysis
2135        if self.should_use_dynamic_json(schema) {
2136            return Ok(SchemaType::Primitive {
2137                rust_type: "serde_json::Value".to_string(),
2138                serde_with: None,
2139            });
2140        }
2141
2142        // Handle allOf composition patterns
2143        if let Schema::AllOf { all_of, .. } = schema {
2144            return self.analyze_allof_composition(all_of, dependencies);
2145        }
2146
2147        // Handle union patterns (anyOf/oneOf) that weren't caught earlier
2148        if let Some(variants) = schema.union_variants() {
2149            match variants.len().cmp(&1) {
2150                std::cmp::Ordering::Equal => {
2151                    // Single variant - analyze it directly
2152                    return self.analyze_property_schema_with_context(
2153                        &variants[0],
2154                        property_name,
2155                        dependencies,
2156                    );
2157                }
2158                std::cmp::Ordering::Greater => {
2159                    // Multiple variants - try to analyze as a union
2160                    // Generate a context-aware name for the union type
2161                    let union_name = if let Some(prop_name) = property_name {
2162                        // We have property context - create a proper union name
2163                        let prop_pascal = self.to_pascal_case(prop_name);
2164                        format!(
2165                            "{}{}",
2166                            self.current_schema_name.as_deref().unwrap_or(""),
2167                            prop_pascal
2168                        )
2169                    } else {
2170                        "UnionType".to_string()
2171                    };
2172
2173                    // Check if this is a oneOf or anyOf
2174                    if let Schema::OneOf {
2175                        one_of,
2176                        discriminator,
2177                        ..
2178                    } = schema
2179                    {
2180                        // This is a oneOf - analyze it properly with potential discriminator
2181                        let oneof_result = self.analyze_oneof_union(
2182                            one_of,
2183                            discriminator.as_ref(),
2184                            &union_name,
2185                            dependencies,
2186                        )?;
2187
2188                        // If we got a union type (not discriminated), we need to store it as a named type
2189                        if let SchemaType::Union {
2190                            variants: _union_variants,
2191                        } = &oneof_result
2192                        {
2193                            // Store the union as a named type in resolved_cache
2194                            self.resolved_cache.insert(
2195                                union_name.clone(),
2196                                AnalyzedSchema {
2197                                    name: union_name.clone(),
2198                                    original: serde_json::to_value(schema).unwrap_or(Value::Null),
2199                                    schema_type: oneof_result.clone(),
2200                                    dependencies: dependencies.clone(),
2201                                    nullable: false,
2202                                    description: schema.details().description.clone(),
2203                                    default: None,
2204                                },
2205                            );
2206
2207                            // Return a reference to the named union type
2208                            dependencies.insert(union_name.clone());
2209                            return Ok(SchemaType::Reference { target: union_name });
2210                        }
2211
2212                        return Ok(oneof_result);
2213                    } else if let Schema::AnyOf {
2214                        any_of,
2215                        discriminator,
2216                        ..
2217                    } = schema
2218                    {
2219                        // This is anyOf - use existing logic with discriminator support
2220                        let union_analysis = self.analyze_anyof_union(
2221                            any_of,
2222                            discriminator.as_ref(),
2223                            dependencies,
2224                            &union_name,
2225                        )?;
2226                        return Ok(union_analysis);
2227                    } else {
2228                        // This shouldn't happen, but handle gracefully
2229                        // Create a simple union from variants
2230                        let mut union_variants = Vec::new();
2231                        for variant in variants {
2232                            if let Some(ref_str) = variant.reference() {
2233                                if let Some(target) = self.extract_schema_name(ref_str) {
2234                                    dependencies.insert(target.to_string());
2235                                    union_variants.push(SchemaRef {
2236                                        target: target.to_string(),
2237                                        nullable: false,
2238                                    });
2239                                }
2240                            }
2241                        }
2242                        return Ok(SchemaType::Union {
2243                            variants: union_variants,
2244                        });
2245                    }
2246                }
2247                std::cmp::Ordering::Less => {}
2248            }
2249        }
2250
2251        // Handle untyped schemas by trying to infer from structure
2252        if let Some(inferred_type) = schema.inferred_type() {
2253            match inferred_type {
2254                OpenApiSchemaType::Object => {
2255                    // Double-check for dynamic JSON pattern even for inferred objects
2256                    if self.should_use_dynamic_json(schema) {
2257                        return Ok(SchemaType::Primitive {
2258                            rust_type: "serde_json::Value".to_string(),
2259                            serde_with: None,
2260                        });
2261                    }
2262                    return self.analyze_object_schema(schema, dependencies);
2263                }
2264                OpenApiSchemaType::Array => {
2265                    let context_name = if let Some(prop_name) = property_name {
2266                        // Use property name for context
2267                        let prop_pascal = self.to_pascal_case(prop_name);
2268                        format!(
2269                            "{}{}",
2270                            self.current_schema_name.as_deref().unwrap_or("Unknown"),
2271                            prop_pascal
2272                        )
2273                    } else {
2274                        // Fallback to generic name
2275                        "ArrayItem".to_string()
2276                    };
2277                    return self.analyze_array_schema(schema, &context_name, dependencies);
2278                }
2279                OpenApiSchemaType::String => {
2280                    if let Some(enum_values) = schema.details().string_enum_values() {
2281                        return Ok(SchemaType::StringEnum {
2282                            values: enum_values,
2283                        });
2284                    } else {
2285                        return Ok(SchemaType::Primitive {
2286                            rust_type: "String".to_string(),
2287                            serde_with: None,
2288                        });
2289                    }
2290                }
2291                _ => {
2292                    // Handle other inferred types
2293                    let rust_type = self.openapi_type_to_rust_type(inferred_type, schema.details());
2294                    return Ok(SchemaType::Primitive {
2295                        rust_type,
2296                        serde_with: None,
2297                    });
2298                }
2299            }
2300        }
2301
2302        Ok(SchemaType::Primitive {
2303            rust_type: "serde_json::Value".to_string(),
2304            serde_with: None,
2305        })
2306    }
2307
2308    fn analyze_allof_composition(
2309        &mut self,
2310        all_of_schemas: &[Schema],
2311        dependencies: &mut HashSet<String>,
2312    ) -> Result<SchemaType> {
2313        // Special case: if allOf contains only a single reference, treat it as a direct type alias
2314        // This handles patterns like: "allOf": [{"$ref": "#/components/schemas/Usage"}]
2315        if all_of_schemas.len() == 1 {
2316            if let Schema::Reference { reference, .. } = &all_of_schemas[0] {
2317                if let Some(target) = self.extract_schema_name(reference) {
2318                    dependencies.insert(target.to_string());
2319                    return Ok(SchemaType::Reference {
2320                        target: target.to_string(),
2321                    });
2322                }
2323            }
2324        }
2325
2326        // AllOf represents schema composition - merge all schemas into one
2327        let mut merged_properties = BTreeMap::new();
2328        let mut merged_required = HashSet::new();
2329        let mut descriptions = Vec::new();
2330
2331        // Save the current schema context to restore it when analyzing properties
2332        let current_context = self.current_schema_name.clone();
2333
2334        for schema in all_of_schemas {
2335            match schema {
2336                Schema::Reference { reference, .. } => {
2337                    // Add dependency on referenced schema
2338                    if let Some(target) = self.extract_schema_name(reference) {
2339                        dependencies.insert(target.to_string());
2340
2341                        // First ensure the referenced schema is analyzed
2342                        let analyzed_ref = self.analyze_schema(target)?;
2343
2344                        // Now merge the analyzed schema's properties
2345                        match &analyzed_ref.schema_type {
2346                            SchemaType::Object {
2347                                properties,
2348                                required,
2349                                ..
2350                            } => {
2351                                // Merge properties from the analyzed schema
2352                                for (prop_name, prop_info) in properties {
2353                                    merged_properties.insert(prop_name.clone(), prop_info.clone());
2354                                }
2355                                // Merge required fields
2356                                for req in required {
2357                                    merged_required.insert(req.clone());
2358                                }
2359                            }
2360                            _ => {
2361                                // If the referenced schema is not an object, fall back to raw merge
2362                                if let Some(ref_schema) = self.schemas.get(target).cloned() {
2363                                    self.merge_schema_into_properties(
2364                                        &ref_schema,
2365                                        &mut merged_properties,
2366                                        &mut merged_required,
2367                                        dependencies,
2368                                    )?;
2369                                }
2370                            }
2371                        }
2372                    }
2373                }
2374                Schema::Typed {
2375                    schema_type: OpenApiSchemaType::Object,
2376                    ..
2377                }
2378                | Schema::Untyped { .. } => {
2379                    // Restore the original context when analyzing inline properties
2380                    let saved_context = self.current_schema_name.clone();
2381                    self.current_schema_name = current_context.clone();
2382
2383                    // Merge object properties directly
2384                    self.merge_schema_into_properties(
2385                        schema,
2386                        &mut merged_properties,
2387                        &mut merged_required,
2388                        dependencies,
2389                    )?;
2390
2391                    // Restore the previous context
2392                    self.current_schema_name = saved_context;
2393                }
2394                _ => {
2395                    // For non-object typed schemas in allOf, try to merge them as well
2396                    // This handles cases like allOf with enum or string constraints
2397                    self.merge_schema_into_properties(
2398                        schema,
2399                        &mut merged_properties,
2400                        &mut merged_required,
2401                        dependencies,
2402                    )?;
2403                }
2404            }
2405
2406            // Collect descriptions
2407            if let Some(desc) = &schema.details().description {
2408                descriptions.push(desc.clone());
2409            }
2410        }
2411
2412        // If we successfully merged properties, return an object
2413        if !merged_properties.is_empty() {
2414            Ok(SchemaType::Object {
2415                properties: merged_properties,
2416                required: merged_required,
2417                additional_properties: ObjectAdditionalProperties::Forbidden,
2418            })
2419        } else {
2420            // Fall back to composition if we couldn't merge
2421            Ok(SchemaType::Composition {
2422                schemas: all_of_schemas
2423                    .iter()
2424                    .filter_map(|s| {
2425                        if let Some(ref_str) = s.reference() {
2426                            if let Some(target) = self.extract_schema_name(ref_str) {
2427                                dependencies.insert(target.to_string());
2428                                Some(SchemaRef {
2429                                    target: target.to_string(),
2430                                    nullable: false,
2431                                })
2432                            } else {
2433                                None
2434                            }
2435                        } else {
2436                            None
2437                        }
2438                    })
2439                    .collect(),
2440            })
2441        }
2442    }
2443
2444    fn merge_schema_into_properties(
2445        &mut self,
2446        schema: &Schema,
2447        merged_properties: &mut BTreeMap<String, PropertyInfo>,
2448        merged_required: &mut HashSet<String>,
2449        dependencies: &mut HashSet<String>,
2450    ) -> Result<()> {
2451        let details = schema.details();
2452
2453        // Merge properties
2454        if let Some(properties) = &details.properties {
2455            for (prop_name, prop_schema) in properties {
2456                let prop_type = self.analyze_property_schema_with_context(
2457                    prop_schema,
2458                    Some(prop_name),
2459                    dependencies,
2460                )?;
2461                let prop_details = prop_schema.details();
2462
2463                // Bug openapi-generator-bgo: pick up 3.1-style nullability
2464                // (anyOf/oneOf with a `type: null` branch) in addition to the
2465                // 3.0 `nullable: true` keyword. Without this, properties merged
2466                // through allOf composition lose their null-branch detection
2467                // (real hit: OpenAI Response.incomplete_details).
2468                let nullable = prop_details.is_nullable() || prop_schema.is_nullable_pattern();
2469                merged_properties.insert(
2470                    prop_name.clone(),
2471                    PropertyInfo {
2472                        schema_type: prop_type,
2473                        nullable,
2474                        description: prop_details.description.clone(),
2475                        default: prop_details.default.clone(),
2476                        serde_attrs: Vec::new(),
2477                        constraints: PropertyConstraints::from_schema_details(prop_details),
2478                    },
2479                );
2480            }
2481        }
2482
2483        // Merge required fields
2484        if let Some(required) = &details.required {
2485            for field in required {
2486                merged_required.insert(field.clone());
2487            }
2488        }
2489
2490        Ok(())
2491    }
2492
2493    fn analyze_oneof_union(
2494        &mut self,
2495        one_of_schemas: &[Schema],
2496        discriminator: Option<&crate::openapi::Discriminator>,
2497        parent_name: &str,
2498        dependencies: &mut HashSet<String>,
2499    ) -> Result<SchemaType> {
2500        // Pattern: nullable [Type, null] — return the non-null type directly.
2501        // The nullable bit is recorded at the property level via is_nullable_pattern().
2502        if one_of_schemas.len() == 2 {
2503            let null_count = one_of_schemas
2504                .iter()
2505                .filter(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2506                .count();
2507            if null_count == 1 {
2508                if let Some(non_null) = one_of_schemas
2509                    .iter()
2510                    .find(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2511                {
2512                    return self
2513                        .analyze_schema_value(non_null, parent_name)
2514                        .map(|a| a.schema_type);
2515                }
2516            }
2517        }
2518
2519        // If there's no discriminator, we should create an untagged union
2520        if discriminator.is_none() {
2521            // Handle untagged unions (oneOf without discriminator)
2522            return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
2523        }
2524
2525        // Bug openapi-generator-dpd: if any branch resolves to a non-object
2526        // schema (e.g. a string-enum like ToolChoiceOptions), serde cannot
2527        // deserialize it via an internally-tagged enum because there is no
2528        // JSON object to read the tag from. Fall back to an untagged union
2529        // so the scalar branch can still match.
2530        if one_of_schemas
2531            .iter()
2532            .any(|s| !self.branch_resolves_to_object(s))
2533        {
2534            return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
2535        }
2536
2537        // This is a discriminated union
2538        let discriminator_field = discriminator
2539            .ok_or_else(|| {
2540                GeneratorError::InvalidDiscriminator(
2541                    "expected discriminator after guard check".to_string(),
2542                )
2543            })?
2544            .property_name
2545            .clone();
2546
2547        let mut variants = Vec::new();
2548        let mut used_variant_names = std::collections::HashSet::new();
2549
2550        for variant_schema in one_of_schemas {
2551            // Check if this is a direct reference, recursive reference, or an allOf wrapper with a reference
2552            let ref_info = if let Some(ref_str) = variant_schema.reference() {
2553                Some((ref_str, false))
2554            } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2555                Some((recursive_ref, true))
2556            } else if let Schema::AllOf { all_of, .. } = variant_schema {
2557                // Check if this is an allOf with a single reference
2558                if all_of.len() == 1 {
2559                    if let Some(ref_str) = all_of[0].reference() {
2560                        Some((ref_str, false))
2561                    } else {
2562                        all_of[0]
2563                            .recursive_reference()
2564                            .map(|recursive_ref| (recursive_ref, true))
2565                    }
2566                } else {
2567                    None
2568                }
2569            } else {
2570                None
2571            };
2572
2573            if let Some((ref_str, is_recursive)) = ref_info {
2574                let schema_name = if is_recursive && ref_str == "#" {
2575                    // Handle recursive reference to the schema with recursiveAnchor
2576                    self.find_recursive_anchor_schema()
2577                        .or_else(|| self.current_schema_name.clone())
2578                        .unwrap_or_else(|| "CompoundFilter".to_string())
2579                } else {
2580                    self.extract_schema_name(ref_str)
2581                        .map(|s| s.to_string())
2582                        .unwrap_or_else(|| "UnknownRef".to_string())
2583                };
2584
2585                if !schema_name.is_empty() {
2586                    dependencies.insert(schema_name.clone());
2587
2588                    // Determine discriminator value with priority order:
2589                    // 1. Explicit mapping in discriminator
2590                    // 2. Extract from referenced schema
2591                    // 3. Generate from schema name
2592                    let discriminator_value = if let Some(disc) = discriminator {
2593                        if let Some(mappings) = &disc.mapping {
2594                            // Find the mapping key that points to this schema reference
2595                            // Mapping format is: "discriminator_value" -> "#/components/schemas/SchemaName"
2596                            mappings
2597                                .iter()
2598                                .find(|(_, target_ref)| {
2599                                    // Check if this mapping target matches our reference
2600                                    target_ref.as_str() == ref_str
2601                                        || self
2602                                            .extract_schema_name(target_ref)
2603                                            .map(|s| s.to_string())
2604                                            == Some(schema_name.clone())
2605                                })
2606                                .map(|(key, _)| key.clone())
2607                                .unwrap_or_else(|| {
2608                                    self.fallback_discriminator_value_for_field(
2609                                        &schema_name,
2610                                        &discriminator_field,
2611                                    )
2612                                })
2613                        } else {
2614                            self.fallback_discriminator_value_for_field(
2615                                &schema_name,
2616                                &discriminator_field,
2617                            )
2618                        }
2619                    } else {
2620                        self.fallback_discriminator_value_for_field(
2621                            &schema_name,
2622                            &discriminator_field,
2623                        )
2624                    };
2625
2626                    // Generate Rust-friendly variant name and ensure uniqueness
2627                    let base_name = self.to_rust_variant_name(&schema_name);
2628                    let rust_name =
2629                        self.ensure_unique_variant_name(base_name, &mut used_variant_names);
2630
2631                    // Use the discriminator value as-is from the schema
2632                    let final_discriminator_value = discriminator_value;
2633
2634                    variants.push(UnionVariant {
2635                        rust_name,
2636                        type_name: schema_name,
2637                        discriminator_value: final_discriminator_value,
2638                        schema_ref: ref_str.to_string(),
2639                    });
2640                }
2641            } else {
2642                // Handle inline schemas in oneOf
2643                let variant_index = variants.len();
2644                let inline_type_name =
2645                    self.generate_inline_type_name(variant_schema, variant_index);
2646
2647                // Try to extract discriminator value from inline schema
2648                let discriminator_value = if let Some(disc) = discriminator {
2649                    if let Some(mappings) = &disc.mapping {
2650                        // Look for mapping that points to this inline variant by index
2651                        mappings
2652                            .iter()
2653                            .find(|(_, target_ref)| {
2654                                target_ref.contains(&format!("variant_{variant_index}"))
2655                            })
2656                            .map(|(key, _)| key.clone())
2657                            .unwrap_or_else(|| {
2658                                self.extract_inline_discriminator_value(
2659                                    variant_schema,
2660                                    &discriminator_field,
2661                                    variant_index,
2662                                )
2663                            })
2664                    } else {
2665                        self.extract_inline_discriminator_value(
2666                            variant_schema,
2667                            &discriminator_field,
2668                            variant_index,
2669                        )
2670                    }
2671                } else {
2672                    self.extract_inline_discriminator_value(
2673                        variant_schema,
2674                        &discriminator_field,
2675                        variant_index,
2676                    )
2677                };
2678
2679                // Generate Rust-friendly variant name based on discriminator or fallback to generic
2680                let base_name = if discriminator_value.starts_with("variant_") {
2681                    format!("Variant{variant_index}")
2682                } else {
2683                    // Convert discriminator value to a meaningful Rust variant name
2684                    let clean_name = self.discriminator_to_variant_name(&discriminator_value);
2685                    self.to_rust_variant_name(&clean_name)
2686                };
2687                let rust_name = self.ensure_unique_variant_name(base_name, &mut used_variant_names);
2688
2689                // Use the discriminator value as-is from the schema
2690                let final_discriminator_value = discriminator_value;
2691
2692                variants.push(UnionVariant {
2693                    rust_name,
2694                    type_name: inline_type_name.clone(),
2695                    discriminator_value: final_discriminator_value,
2696                    schema_ref: format!("inline_{variant_index}"),
2697                });
2698
2699                // Store inline schema for later analysis and generation
2700                self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
2701            }
2702        }
2703
2704        if variants.is_empty() {
2705            // If we couldn't create a discriminated union, fall back to an untagged union
2706            // This handles cases where oneOf contains references or inline schemas without proper discriminators
2707            let mut union_variants = Vec::new();
2708
2709            for (variant_index, variant_schema) in one_of_schemas.iter().enumerate() {
2710                // First check if it's a reference or recursive reference
2711                if let Some(ref_str) = variant_schema.reference() {
2712                    if let Some(schema_name) = self.extract_schema_name(ref_str) {
2713                        dependencies.insert(schema_name.to_string());
2714                        union_variants.push(SchemaRef {
2715                            target: schema_name.to_string(),
2716                            nullable: false,
2717                        });
2718                    }
2719                } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2720                    let schema_name = if recursive_ref == "#" {
2721                        // Handle recursive reference to the schema with recursiveAnchor
2722                        self.find_recursive_anchor_schema()
2723                            .or_else(|| self.current_schema_name.clone())
2724                            .unwrap_or_else(|| "CompoundFilter".to_string())
2725                    } else {
2726                        self.extract_schema_name(recursive_ref)
2727                            .map(|s| s.to_string())
2728                            .unwrap_or_else(|| "RecursiveType".to_string())
2729                    };
2730                    dependencies.insert(schema_name.clone());
2731                    union_variants.push(SchemaRef {
2732                        target: schema_name,
2733                        nullable: false,
2734                    });
2735                } else {
2736                    // Handle inline schemas by creating type aliases or using primitive types directly
2737                    let inline_name = self.generate_context_aware_name(
2738                        parent_name,
2739                        "InlineVariant",
2740                        variant_index,
2741                        Some(variant_schema),
2742                    );
2743                    let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
2744                    let variant_type = analyzed.schema_type;
2745
2746                    // Add dependencies from the analyzed schema
2747                    for dep in &analyzed.dependencies {
2748                        dependencies.insert(dep.clone());
2749                    }
2750
2751                    match &variant_type {
2752                        // For primitive types, we can use them directly in the union
2753                        SchemaType::Primitive { rust_type, .. } => {
2754                            union_variants.push(SchemaRef {
2755                                target: rust_type.clone(),
2756                                nullable: false,
2757                            });
2758                        }
2759                        // For arrays, check if we can determine the item type
2760                        SchemaType::Array { item_type } => {
2761                            match item_type.as_ref() {
2762                                SchemaType::Primitive { rust_type, .. } => {
2763                                    let type_name = format!("Vec<{rust_type}>");
2764                                    union_variants.push(SchemaRef {
2765                                        target: type_name,
2766                                        nullable: false,
2767                                    });
2768                                }
2769                                SchemaType::Reference { target } => {
2770                                    let type_name = format!("Vec<{target}>");
2771                                    union_variants.push(SchemaRef {
2772                                        target: type_name,
2773                                        nullable: false,
2774                                    });
2775                                }
2776                                _ => {
2777                                    // For other array types, create an inline type
2778                                    let inline_type_name = self.generate_context_aware_name(
2779                                        parent_name,
2780                                        "Variant",
2781                                        variant_index,
2782                                        None,
2783                                    );
2784                                    self.add_inline_schema(
2785                                        &inline_type_name,
2786                                        variant_schema,
2787                                        dependencies,
2788                                    )?;
2789                                    union_variants.push(SchemaRef {
2790                                        target: inline_type_name,
2791                                        nullable: false,
2792                                    });
2793                                }
2794                            }
2795                        }
2796                        // For reference types, use the reference target directly
2797                        SchemaType::Reference { target } => {
2798                            union_variants.push(SchemaRef {
2799                                target: target.clone(),
2800                                nullable: false,
2801                            });
2802                        }
2803                        // For other complex types, create an inline type
2804                        _ => {
2805                            let inline_type_name =
2806                                format!("{}Variant{}", parent_name, variant_index + 1);
2807                            self.add_inline_schema(
2808                                &inline_type_name,
2809                                variant_schema,
2810                                dependencies,
2811                            )?;
2812                            union_variants.push(SchemaRef {
2813                                target: inline_type_name,
2814                                nullable: false,
2815                            });
2816                        }
2817                    }
2818                }
2819            }
2820
2821            if !union_variants.is_empty() {
2822                return Ok(SchemaType::Union {
2823                    variants: union_variants,
2824                });
2825            }
2826
2827            // Only fall back to serde_json::Value if we truly can't analyze the union
2828            return Ok(SchemaType::Primitive {
2829                rust_type: "serde_json::Value".to_string(),
2830                serde_with: None,
2831            });
2832        }
2833
2834        Ok(SchemaType::DiscriminatedUnion {
2835            discriminator_field,
2836            variants,
2837        })
2838    }
2839
2840    fn analyze_untagged_oneof_union(
2841        &mut self,
2842        one_of_schemas: &[Schema],
2843        parent_name: &str,
2844        dependencies: &mut HashSet<String>,
2845    ) -> Result<SchemaType> {
2846        // Drop {"type": "null"} variants. They mean "may be null" and are surfaced
2847        // as Option<T> at the property level — including them here produces a junk
2848        // `SerdeJsonValue(serde_json::Value)` variant.
2849        let filtered: Vec<&Schema> = one_of_schemas
2850            .iter()
2851            .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2852            .collect();
2853
2854        // If filtering leaves a single variant, return its analyzed type directly.
2855        if filtered.len() == 1 {
2856            return self
2857                .analyze_schema_value(filtered[0], parent_name)
2858                .map(|a| a.schema_type);
2859        }
2860
2861        let mut union_variants = Vec::new();
2862
2863        for (variant_index, variant_schema) in filtered.iter().copied().enumerate() {
2864            // First check if it's a reference or recursive reference
2865            if let Some(ref_str) = variant_schema.reference() {
2866                if let Some(schema_name) = self.extract_schema_name(ref_str) {
2867                    dependencies.insert(schema_name.to_string());
2868                    union_variants.push(SchemaRef {
2869                        target: schema_name.to_string(),
2870                        nullable: false,
2871                    });
2872                }
2873            } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2874                let schema_name = if recursive_ref == "#" {
2875                    // Handle recursive reference to the schema with recursiveAnchor
2876                    self.find_recursive_anchor_schema()
2877                        .or_else(|| self.current_schema_name.clone())
2878                        .unwrap_or_else(|| "CompoundFilter".to_string())
2879                } else {
2880                    self.extract_schema_name(recursive_ref)
2881                        .map(|s| s.to_string())
2882                        .unwrap_or_else(|| "RecursiveType".to_string())
2883                };
2884                dependencies.insert(schema_name.clone());
2885                union_variants.push(SchemaRef {
2886                    target: schema_name,
2887                    nullable: false,
2888                });
2889            } else {
2890                // Handle inline schemas by creating type aliases or using primitive types directly
2891                let inline_name = self.generate_context_aware_name(
2892                    parent_name,
2893                    "InlineVariant",
2894                    variant_index,
2895                    Some(variant_schema),
2896                );
2897                let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
2898                let variant_type = analyzed.schema_type;
2899
2900                // Add dependencies from the analyzed schema
2901                for dep in &analyzed.dependencies {
2902                    dependencies.insert(dep.clone());
2903                }
2904
2905                match &variant_type {
2906                    // For primitive types, we can use them directly in the union
2907                    SchemaType::Primitive { rust_type, .. } => {
2908                        union_variants.push(SchemaRef {
2909                            target: rust_type.clone(),
2910                            nullable: false,
2911                        });
2912                    }
2913                    // For arrays, check if we can determine the item type
2914                    SchemaType::Array { item_type } => {
2915                        match item_type.as_ref() {
2916                            SchemaType::Primitive { rust_type, .. } => {
2917                                let type_name = format!("Vec<{rust_type}>");
2918                                union_variants.push(SchemaRef {
2919                                    target: type_name,
2920                                    nullable: false,
2921                                });
2922                            }
2923                            SchemaType::Reference { target } => {
2924                                let type_name = format!("Vec<{target}>");
2925                                union_variants.push(SchemaRef {
2926                                    target: type_name,
2927                                    nullable: false,
2928                                });
2929                            }
2930                            // Handle arrays of arrays (e.g., Vec<Vec<i64>>)
2931                            SchemaType::Array {
2932                                item_type: inner_item_type,
2933                            } => {
2934                                match inner_item_type.as_ref() {
2935                                    SchemaType::Primitive { rust_type, .. } => {
2936                                        let type_name = format!("Vec<Vec<{rust_type}>>");
2937                                        union_variants.push(SchemaRef {
2938                                            target: type_name,
2939                                            nullable: false,
2940                                        });
2941                                    }
2942                                    SchemaType::Reference { target } => {
2943                                        let type_name = format!("Vec<Vec<{target}>>");
2944                                        union_variants.push(SchemaRef {
2945                                            target: type_name,
2946                                            nullable: false,
2947                                        });
2948                                    }
2949                                    _ => {
2950                                        // For deeper nesting, create an inline type
2951                                        let inline_type_name = self.generate_context_aware_name(
2952                                            parent_name,
2953                                            "Variant",
2954                                            variant_index,
2955                                            None,
2956                                        );
2957                                        self.add_inline_schema(
2958                                            &inline_type_name,
2959                                            variant_schema,
2960                                            dependencies,
2961                                        )?;
2962                                        union_variants.push(SchemaRef {
2963                                            target: inline_type_name,
2964                                            nullable: false,
2965                                        });
2966                                    }
2967                                }
2968                            }
2969                            _ => {
2970                                // For other array types, create an inline type
2971                                let inline_type_name = self.generate_context_aware_name(
2972                                    parent_name,
2973                                    "Variant",
2974                                    variant_index,
2975                                    None,
2976                                );
2977                                self.add_inline_schema(
2978                                    &inline_type_name,
2979                                    variant_schema,
2980                                    dependencies,
2981                                )?;
2982                                union_variants.push(SchemaRef {
2983                                    target: inline_type_name,
2984                                    nullable: false,
2985                                });
2986                            }
2987                        }
2988                    }
2989                    // For reference types, use the reference target directly
2990                    SchemaType::Reference { target } => {
2991                        union_variants.push(SchemaRef {
2992                            target: target.clone(),
2993                            nullable: false,
2994                        });
2995                    }
2996                    // For other complex types, create an inline type
2997                    _ => {
2998                        let inline_type_name = self.generate_context_aware_name(
2999                            parent_name,
3000                            "Variant",
3001                            variant_index,
3002                            None,
3003                        );
3004                        self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
3005                        union_variants.push(SchemaRef {
3006                            target: inline_type_name,
3007                            nullable: false,
3008                        });
3009                    }
3010                }
3011            }
3012        }
3013
3014        if !union_variants.is_empty() {
3015            return Ok(SchemaType::Union {
3016                variants: union_variants,
3017            });
3018        }
3019
3020        // Only fall back to serde_json::Value if we truly can't analyze the union
3021        Ok(SchemaType::Primitive {
3022            rust_type: "serde_json::Value".to_string(),
3023            serde_with: None,
3024        })
3025    }
3026
3027    fn add_inline_schema(
3028        &mut self,
3029        type_name: &str,
3030        schema: &Schema,
3031        dependencies: &mut HashSet<String>,
3032    ) -> Result<()> {
3033        // For primitive types, we need to ensure they are stored as type aliases
3034        if let Some(schema_type) = schema.schema_type() {
3035            match schema_type {
3036                OpenApiSchemaType::String
3037                | OpenApiSchemaType::Integer
3038                | OpenApiSchemaType::Number
3039                | OpenApiSchemaType::Boolean => {
3040                    let rust_type =
3041                        self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
3042
3043                    // Store as a type alias
3044                    self.resolved_cache.insert(
3045                        type_name.to_string(),
3046                        AnalyzedSchema {
3047                            name: type_name.to_string(),
3048                            original: serde_json::to_value(schema).unwrap_or(Value::Null),
3049                            schema_type: SchemaType::Primitive {
3050                                rust_type,
3051                                serde_with: None,
3052                            },
3053                            dependencies: HashSet::new(),
3054                            nullable: false,
3055                            description: schema.details().description.clone(),
3056                            default: None,
3057                        },
3058                    );
3059                    return Ok(());
3060                }
3061                _ => {}
3062            }
3063        }
3064
3065        // For non-primitive types, analyze the inline schema and add it to our collection
3066        // Set current_schema_name so nested inline properties (enums, unions, objects)
3067        // get named with the correct parent context instead of inheriting a stale name
3068        let previous_schema_name = self.current_schema_name.take();
3069        self.current_schema_name = Some(type_name.to_string());
3070        let analyzed = self.analyze_schema_value(schema, type_name)?;
3071        self.current_schema_name = previous_schema_name;
3072
3073        // Add to resolved cache so it can be generated
3074        self.resolved_cache.insert(type_name.to_string(), analyzed);
3075
3076        // Add dependencies
3077        if let Some(cached) = self.resolved_cache.get(type_name) {
3078            for dep in &cached.dependencies {
3079                dependencies.insert(dep.clone());
3080            }
3081        }
3082
3083        Ok(())
3084    }
3085
3086    fn extract_inline_discriminator_value(
3087        &self,
3088        schema: &Schema,
3089        discriminator_field: &str,
3090        variant_index: usize,
3091    ) -> String {
3092        // Try to extract discriminator value from inline schema properties
3093        if let Some(properties) = &schema.details().properties {
3094            if let Some(discriminator_prop) = properties.get(discriminator_field) {
3095                // Check for enum with single value
3096                if let Some(enum_values) = &discriminator_prop.details().enum_values {
3097                    if enum_values.len() == 1 {
3098                        if let Some(value) = enum_values[0].as_str() {
3099                            return value.to_string();
3100                        }
3101                    }
3102                }
3103                // Check for const value in extra fields
3104                if let Some(const_value) = discriminator_prop.details().extra.get("const") {
3105                    if let Some(value) = const_value.as_str() {
3106                        return value.to_string();
3107                    }
3108                }
3109                // Check for const value in the discriminator_prop.details().const_value
3110                if let Some(const_value) = &discriminator_prop.details().const_value {
3111                    if let Some(value) = const_value.as_str() {
3112                        return value.to_string();
3113                    }
3114                }
3115            }
3116        }
3117
3118        // Try to infer from schema structure and properties
3119        if let Some(inferred_name) = self.infer_variant_name_from_structure(schema, variant_index) {
3120            return inferred_name;
3121        }
3122
3123        // Fall back to generic variant name
3124        format!("variant_{variant_index}")
3125    }
3126
3127    fn infer_variant_name_from_structure(
3128        &self,
3129        schema: &Schema,
3130        _variant_index: usize,
3131    ) -> Option<String> {
3132        let details = schema.details();
3133
3134        // Strategy 1: Look for unique property combinations that suggest the variant type
3135        if let Some(properties) = &details.properties {
3136            // Common patterns for content blocks
3137            if properties.contains_key("text") && properties.len() <= 3 {
3138                return Some("text".to_string());
3139            }
3140            if properties.contains_key("image") || properties.contains_key("source") {
3141                return Some("image".to_string());
3142            }
3143            if properties.contains_key("document") {
3144                return Some("document".to_string());
3145            }
3146            if properties.contains_key("tool_use_id") || properties.contains_key("tool_result") {
3147                return Some("tool_result".to_string());
3148            }
3149            if properties.contains_key("content") && properties.contains_key("is_error") {
3150                return Some("tool_result".to_string());
3151            }
3152            if properties.contains_key("partial_json") {
3153                return Some("partial_json".to_string());
3154            }
3155
3156            // Strategy 2: Look for properties that hint at the variant purpose
3157            let property_names: Vec<&String> = properties.keys().collect();
3158
3159            // Try to find the most descriptive property name
3160            for prop_name in &property_names {
3161                if prop_name.contains("result") {
3162                    return Some("result".to_string());
3163                }
3164                if prop_name.contains("error") {
3165                    return Some("error".to_string());
3166                }
3167                if prop_name.contains("content") && property_names.len() <= 2 {
3168                    return Some("content".to_string());
3169                }
3170            }
3171
3172            // Strategy 3: Use the most significant unique property
3173            let significant_props = property_names
3174                .iter()
3175                .filter(|&name| !["type", "id", "cache_control"].contains(&name.as_str()))
3176                .collect::<Vec<_>>();
3177
3178            if significant_props.len() == 1 {
3179                return Some((*significant_props[0]).clone());
3180            }
3181        }
3182
3183        // Strategy 4: Look at description for hints
3184        if let Some(description) = &details.description {
3185            let desc_lower = description.to_lowercase();
3186            if desc_lower.contains("text") && desc_lower.len() < 100 {
3187                return Some("text".to_string());
3188            }
3189            if desc_lower.contains("image") {
3190                return Some("image".to_string());
3191            }
3192            if desc_lower.contains("document") {
3193                return Some("document".to_string());
3194            }
3195            if desc_lower.contains("tool") && desc_lower.contains("result") {
3196                return Some("tool_result".to_string());
3197            }
3198        }
3199
3200        None
3201    }
3202
3203    fn discriminator_to_variant_name(&self, discriminator: &str) -> String {
3204        // Convert discriminator values to PascalCase variant names using general rules
3205        if discriminator.is_empty() {
3206            return "Variant".to_string();
3207        }
3208
3209        let mut result = String::new();
3210        let mut next_upper = true;
3211
3212        for c in discriminator.chars() {
3213            match c {
3214                'a'..='z' => {
3215                    if next_upper {
3216                        result.push(c.to_ascii_uppercase());
3217                        next_upper = false;
3218                    } else {
3219                        result.push(c);
3220                    }
3221                }
3222                'A'..='Z' => {
3223                    result.push(c);
3224                    next_upper = false;
3225                }
3226                '0'..='9' => {
3227                    result.push(c);
3228                    next_upper = false;
3229                }
3230                '_' | '-' | '.' | ' ' | '/' | '\\' => {
3231                    // Word separators - next char should be uppercase
3232                    next_upper = true;
3233                }
3234                _ => {
3235                    // Other special characters - treat as word boundary
3236                    next_upper = true;
3237                }
3238            }
3239        }
3240
3241        // Ensure it starts with a letter
3242        if result.is_empty() || result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
3243            result = format!("Variant{result}");
3244        }
3245
3246        result
3247    }
3248
3249    fn ensure_unique_variant_name(
3250        &self,
3251        base_name: String,
3252        used_names: &mut std::collections::HashSet<String>,
3253    ) -> String {
3254        let mut candidate = base_name.clone();
3255        let mut counter = 1;
3256
3257        while used_names.contains(&candidate) {
3258            counter += 1;
3259            candidate = format!("{base_name}{counter}");
3260        }
3261
3262        used_names.insert(candidate.clone());
3263        candidate
3264    }
3265
3266    fn generate_inline_type_name(&self, schema: &Schema, variant_index: usize) -> String {
3267        // Try to generate a meaningful name for inline schemas
3268        if let Some(meaningful_name) = self.infer_type_name_from_structure(schema) {
3269            return meaningful_name;
3270        }
3271
3272        // Fallback to context-aware name
3273        let context = self.current_schema_name.as_deref().unwrap_or("Inline");
3274        self.generate_context_aware_name(context, "Variant", variant_index, Some(schema))
3275    }
3276
3277    fn infer_type_name_from_structure(&self, schema: &Schema) -> Option<String> {
3278        let details = schema.details();
3279
3280        // Strategy 1: Use description if it's short and descriptive
3281        if let Some(description) = &details.description {
3282            if let Some(name_from_desc) = self.extract_type_name_from_description(description) {
3283                return Some(name_from_desc);
3284            }
3285        }
3286
3287        // Strategy 2: Use the most significant property name as the type identifier
3288        if let Some(properties) = &details.properties {
3289            if let Some(name_from_props) = self.extract_type_name_from_properties(properties) {
3290                return Some(format!("{name_from_props}Block"));
3291            }
3292        }
3293
3294        None
3295    }
3296
3297    fn extract_type_name_from_description(&self, description: &str) -> Option<String> {
3298        // Only use descriptions that are short and likely to be type identifiers
3299        if description.len() > 100 || description.contains('\n') {
3300            return None;
3301        }
3302
3303        // Extract the first meaningful word(s) from the description
3304        let words: Vec<&str> = description
3305            .split_whitespace()
3306            .take(2) // Only take first 2 words to avoid long names
3307            .filter(|word| {
3308                let w = word.to_lowercase();
3309                word.len() > 2
3310                    && ![
3311                        "the", "and", "for", "with", "that", "this", "are", "can", "will", "was",
3312                    ]
3313                    .contains(&w.as_str())
3314            })
3315            .collect();
3316
3317        if words.is_empty() {
3318            return None;
3319        }
3320
3321        // Convert to PascalCase using our existing logic
3322        let combined = words.join("_");
3323        let pascal_name = self.discriminator_to_variant_name(&combined);
3324
3325        // Add suffix if it doesn't already have one
3326        if !pascal_name.ends_with("Content")
3327            && !pascal_name.ends_with("Block")
3328            && !pascal_name.ends_with("Type")
3329        {
3330            Some(format!("{pascal_name}Content"))
3331        } else {
3332            Some(pascal_name)
3333        }
3334    }
3335
3336    fn extract_type_name_from_properties(
3337        &self,
3338        properties: &std::collections::BTreeMap<String, crate::openapi::Schema>,
3339    ) -> Option<String> {
3340        // Get property names, excluding common structural properties
3341        let significant_props: Vec<&String> = properties
3342            .keys()
3343            .filter(|name| !["type", "id", "cache_control"].contains(&name.as_str()))
3344            .collect();
3345
3346        if significant_props.is_empty() {
3347            return None;
3348        }
3349
3350        // Strategy 1: If there's only one significant property, use it
3351        if significant_props.len() == 1 {
3352            let prop_name = significant_props[0];
3353            return Some(self.discriminator_to_variant_name(prop_name));
3354        }
3355
3356        // Strategy 2: Use the first property alphabetically for consistency
3357        // This provides deterministic naming without hardcoded preferences
3358        let mut sorted_props = significant_props.clone();
3359        sorted_props.sort();
3360        if let Some(first_prop) = sorted_props.first() {
3361            return Some(self.discriminator_to_variant_name(first_prop));
3362        }
3363
3364        None
3365    }
3366
3367    fn openapi_type_to_rust_type(
3368        &self,
3369        openapi_type: OpenApiSchemaType,
3370        details: &crate::openapi::SchemaDetails,
3371    ) -> String {
3372        // Q2.0: route through the TypeMapper chokepoint. With the default
3373        // config this produces bit-identical output to the pre-refactor
3374        // match; later Q2.* issues add format-aware branches inside
3375        // TypeMapper without touching this function.
3376        self.type_mapper.map(openapi_type, details).rust_type
3377    }
3378
3379    #[allow(dead_code)]
3380    fn fallback_discriminator_value(&self, schema_name: &str) -> String {
3381        self.fallback_discriminator_value_for_field(schema_name, "type")
3382    }
3383
3384    fn fallback_discriminator_value_for_field(
3385        &self,
3386        schema_name: &str,
3387        field_name: &str,
3388    ) -> String {
3389        // Try to extract from referenced schema first
3390        if let Some(ref_schema) = self.schemas.get(schema_name) {
3391            if let Some(extracted) =
3392                self.extract_discriminator_value_for_field(ref_schema, field_name)
3393            {
3394                return extracted;
3395            }
3396        }
3397
3398        // Fall back to generating from name
3399        self.generate_discriminator_value_from_name(schema_name)
3400    }
3401
3402    fn generate_discriminator_value_from_name(&self, schema_name: &str) -> String {
3403        // Convert schema names like "ResponseCreatedEvent" to "response.created"
3404        let mut result = String::new();
3405        let mut chars = schema_name.chars().peekable();
3406        let mut first = true;
3407
3408        while let Some(c) = chars.next() {
3409            if c.is_uppercase()
3410                && !first
3411                && chars
3412                    .peek()
3413                    .map(|&next| next.is_lowercase())
3414                    .unwrap_or(false)
3415            {
3416                result.push('.');
3417            }
3418            result.push(c.to_ascii_lowercase());
3419            first = false;
3420        }
3421
3422        // Remove common suffixes
3423        if result.ends_with("event") {
3424            result = result[..result.len() - 5].to_string();
3425        }
3426
3427        // Add "response." prefix if it looks like a response event
3428        if schema_name.starts_with("Response") && !result.starts_with("response.") {
3429            result = format!("response.{}", result.trim_start_matches("response"));
3430        }
3431
3432        result
3433    }
3434
3435    fn to_rust_variant_name(&self, schema_name: &str) -> String {
3436        // Convert "ResponseCreatedEvent" to "Created", "UserStatus" to "UserStatus", etc.
3437        let mut name = schema_name;
3438
3439        // Remove common prefixes for cleaner variant names
3440        if name.starts_with("Response") && name.len() > 8 {
3441            name = &name[8..]; // Remove "Response"
3442        }
3443
3444        // Remove common suffixes
3445        if name.ends_with("Event") && name.len() > 5 {
3446            name = &name[..name.len() - 5]; // Remove "Event"
3447        }
3448
3449        // Trim leading and trailing underscores
3450        name = name.trim_matches('_');
3451
3452        // Convert underscores to camel case using our existing function
3453        if name.is_empty() {
3454            schema_name.to_string()
3455        } else {
3456            // Use discriminator_to_variant_name to properly handle underscores
3457            self.discriminator_to_variant_name(name)
3458        }
3459    }
3460
3461    /// Register an inline string enum as a named `StringEnum` schema and
3462    /// return a `Reference` to it. Shared by property-level enums
3463    /// (`{Schema}{Prop}`) and array-item enums (`{Schema}{Prop}Item`).
3464    ///
3465    /// Resolves a name that either matches an existing same-valued
3466    /// enum (dedup) or doesn't collide with a different one.
3467    ///
3468    /// Two distinct inline enums can land on the same primary
3469    /// candidate when a parent schema has a property like
3470    /// `type` that recurs at multiple nesting levels — e.g.
3471    /// Latitude.sh's `plan_data.type = ["plans"]` (the
3472    /// JSON-API resource type) and
3473    /// `plan_data.attributes.specs.drives[].type =
3474    /// ["SSD","HDD","NVME"]` both want to become
3475    /// `PlanDataType`. We must NOT silently overwrite the
3476    /// first registration: that breaks deserialization
3477    /// because both fields end up referencing whichever
3478    /// enum was processed last.
3479    ///
3480    /// Disambiguation strategy: append the PascalCase first
3481    /// enum value (`PlanDataTypeNVME` vs `PlanDataTypePlans`)
3482    /// and, if that's also claimed with different values,
3483    /// fall back to a numeric `_2`, `_3`, … suffix.
3484    fn hoist_inline_string_enum(
3485        &mut self,
3486        schema: &Schema,
3487        enum_values: Vec<String>,
3488        primary_name: String,
3489        dependencies: &mut HashSet<String>,
3490    ) -> SchemaType {
3491        fn matches_values(existing: &AnalyzedSchema, values: &[String]) -> bool {
3492            matches!(
3493                &existing.schema_type,
3494                SchemaType::StringEnum { values: existing_values }
3495                    if existing_values == values
3496            )
3497        }
3498
3499        let mut enum_type_name = primary_name.clone();
3500        let should_insert = match self.resolved_cache.get(&enum_type_name) {
3501            None => true,
3502            Some(existing) if matches_values(existing, &enum_values) => false,
3503            Some(_) => {
3504                // Collision with different values — try a
3505                // value-suffixed name first.
3506                let suffix = enum_values
3507                    .first()
3508                    .map(|v| self.to_pascal_case(v))
3509                    .unwrap_or_else(|| "Variant".to_string());
3510                let candidate = format!("{primary_name}{suffix}");
3511
3512                let resolved = match self.resolved_cache.get(&candidate) {
3513                    None => Some((candidate.clone(), true)),
3514                    Some(existing) if matches_values(existing, &enum_values) => {
3515                        Some((candidate.clone(), false))
3516                    }
3517                    Some(_) => {
3518                        // Walk a numeric suffix until we find
3519                        // a slot that's free or matches.
3520                        let mut found = None;
3521                        for n in 2..1000 {
3522                            let numbered = format!("{candidate}_{n}");
3523                            match self.resolved_cache.get(&numbered) {
3524                                None => {
3525                                    found = Some((numbered, true));
3526                                    break;
3527                                }
3528                                Some(existing) if matches_values(existing, &enum_values) => {
3529                                    found = Some((numbered, false));
3530                                    break;
3531                                }
3532                                Some(_) => continue,
3533                            }
3534                        }
3535                        found
3536                    }
3537                };
3538
3539                let (resolved_name, insert) = resolved.unwrap_or((candidate, true));
3540                enum_type_name = resolved_name;
3541                insert
3542            }
3543        };
3544
3545        // Store the enum as a named schema if this is the
3546        // first time we've seen this exact (name, values) pair.
3547        if should_insert {
3548            self.resolved_cache.insert(
3549                enum_type_name.clone(),
3550                AnalyzedSchema {
3551                    name: enum_type_name.clone(),
3552                    original: serde_json::to_value(schema).unwrap_or(Value::Null),
3553                    schema_type: SchemaType::StringEnum {
3554                        values: enum_values,
3555                    },
3556                    dependencies: HashSet::new(),
3557                    nullable: false,
3558                    description: schema.details().description.clone(),
3559                    default: schema.details().default.clone(),
3560                },
3561            );
3562        }
3563
3564        // Return a reference to the named enum type
3565        dependencies.insert(enum_type_name.clone());
3566        SchemaType::Reference {
3567            target: enum_type_name,
3568        }
3569    }
3570
3571    fn analyze_array_schema(
3572        &mut self,
3573        schema: &Schema,
3574        parent_schema_name: &str,
3575        dependencies: &mut HashSet<String>,
3576    ) -> Result<SchemaType> {
3577        let details = schema.details();
3578
3579        // Check if items field is present
3580        if let Some(items_schema) = &details.items {
3581            // Analyze the item type
3582            let item_type = match items_schema.as_ref() {
3583                Schema::Reference { reference, .. } => {
3584                    // Array of referenced types
3585                    let target = self
3586                        .extract_schema_name(reference)
3587                        .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))?
3588                        .to_string();
3589                    dependencies.insert(target.clone());
3590                    SchemaType::Reference { target }
3591                }
3592                Schema::RecursiveRef { recursive_ref, .. } => {
3593                    // Array of recursive references
3594                    if recursive_ref == "#" {
3595                        // Self-reference to the current schema
3596                        let target = self
3597                            .find_recursive_anchor_schema()
3598                            .unwrap_or_else(|| parent_schema_name.to_string());
3599                        dependencies.insert(target.clone());
3600                        SchemaType::Reference { target }
3601                    } else {
3602                        let target = self
3603                            .extract_schema_name(recursive_ref)
3604                            .unwrap_or("RecursiveType")
3605                            .to_string();
3606                        dependencies.insert(target.clone());
3607                        SchemaType::Reference { target }
3608                    }
3609                }
3610                Schema::Typed { schema_type, .. } => {
3611                    // Array of primitive types
3612                    match schema_type {
3613                        OpenApiSchemaType::String => {
3614                            // Inline string enum in array items — hoist to a
3615                            // named enum (`{Parent}Item`) instead of collapsing
3616                            // to `Vec<String>`.
3617                            match items_schema
3618                                .details()
3619                                .string_enum_values()
3620                                .filter(|values| !values.is_empty())
3621                            {
3622                                Some(values) => self.hoist_inline_string_enum(
3623                                    items_schema,
3624                                    values,
3625                                    format!("{parent_schema_name}Item"),
3626                                    dependencies,
3627                                ),
3628                                None => SchemaType::Primitive {
3629                                    rust_type: "String".to_string(),
3630                                    serde_with: None,
3631                                },
3632                            }
3633                        }
3634                        OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
3635                            let details = items_schema.details();
3636                            let rust_type = self.get_number_rust_type(schema_type.clone(), details);
3637                            SchemaType::Primitive {
3638                                rust_type,
3639                                serde_with: None,
3640                            }
3641                        }
3642                        OpenApiSchemaType::Boolean => SchemaType::Primitive {
3643                            rust_type: "bool".to_string(),
3644                            serde_with: None,
3645                        },
3646                        OpenApiSchemaType::Object => {
3647                            // Inline object in array - create a named schema for it
3648                            let object_type_name = format!("{parent_schema_name}Item");
3649
3650                            // Analyze the object schema
3651                            let object_type =
3652                                self.analyze_object_schema(items_schema, dependencies)?;
3653
3654                            // Create an analyzed schema for the inline object
3655                            let inline_schema = AnalyzedSchema {
3656                                name: object_type_name.clone(),
3657                                original: serde_json::to_value(items_schema).unwrap_or(Value::Null),
3658                                schema_type: object_type,
3659                                dependencies: dependencies.clone(),
3660                                nullable: false,
3661                                description: items_schema.details().description.clone(),
3662                                default: None,
3663                            };
3664
3665                            // Add the inline object as a named schema
3666                            self.resolved_cache
3667                                .insert(object_type_name.clone(), inline_schema);
3668                            dependencies.insert(object_type_name.clone());
3669
3670                            // Return a reference to the named schema
3671                            SchemaType::Reference {
3672                                target: object_type_name,
3673                            }
3674                        }
3675                        OpenApiSchemaType::Array => {
3676                            // Array of arrays - recursively analyze
3677                            self.analyze_array_schema(
3678                                items_schema,
3679                                parent_schema_name,
3680                                dependencies,
3681                            )?
3682                        }
3683                        _ => SchemaType::Primitive {
3684                            rust_type: "serde_json::Value".to_string(),
3685                            serde_with: None,
3686                        },
3687                    }
3688                }
3689                Schema::OneOf { .. } | Schema::AnyOf { .. } => {
3690                    // Union types in arrays - analyze recursively
3691                    let analyzed = self.analyze_schema_value(items_schema, "ArrayItem")?;
3692
3693                    // If we got a discriminated union or union, we need to create a separate schema for it
3694                    match &analyzed.schema_type {
3695                        SchemaType::DiscriminatedUnion { .. } | SchemaType::Union { .. } => {
3696                            // Generate a unique name for the union schema based on the parent context
3697                            // Use the parent context directly to maintain consistent naming
3698                            let union_name = format!("{parent_schema_name}ItemUnion");
3699
3700                            // Create a new analyzed schema with the correct name
3701                            let mut union_schema = analyzed;
3702                            union_schema.name = union_name.clone();
3703
3704                            // Add the union as a separate schema
3705                            self.resolved_cache.insert(union_name.clone(), union_schema);
3706
3707                            // Add dependency
3708                            dependencies.insert(union_name.clone());
3709
3710                            // Return a reference to the union schema
3711                            SchemaType::Reference { target: union_name }
3712                        }
3713                        _ => analyzed.schema_type,
3714                    }
3715                }
3716                Schema::Untyped { .. } => {
3717                    // Try to infer the type
3718                    if let Some(inferred) = items_schema.inferred_type() {
3719                        match inferred {
3720                            OpenApiSchemaType::Object => {
3721                                // Inline object in array - create a named schema for it
3722                                let object_type_name = format!("{parent_schema_name}Item");
3723
3724                                // Analyze the object schema
3725                                let object_type =
3726                                    self.analyze_object_schema(items_schema, dependencies)?;
3727
3728                                // Create an analyzed schema for the inline object
3729                                let inline_schema = AnalyzedSchema {
3730                                    name: object_type_name.clone(),
3731                                    original: serde_json::to_value(items_schema)
3732                                        .unwrap_or(Value::Null),
3733                                    schema_type: object_type,
3734                                    dependencies: dependencies.clone(),
3735                                    nullable: false,
3736                                    description: items_schema.details().description.clone(),
3737                                    default: None,
3738                                };
3739
3740                                // Add the inline object as a named schema
3741                                self.resolved_cache
3742                                    .insert(object_type_name.clone(), inline_schema);
3743                                dependencies.insert(object_type_name.clone());
3744
3745                                // Return a reference to the named schema
3746                                SchemaType::Reference {
3747                                    target: object_type_name,
3748                                }
3749                            }
3750                            OpenApiSchemaType::String => {
3751                                // Typeless (OpenAPI 3.1) enum in array items —
3752                                // same hoisting as the typed-string arm.
3753                                match items_schema
3754                                    .details()
3755                                    .string_enum_values()
3756                                    .filter(|values| !values.is_empty())
3757                                {
3758                                    Some(values) => self.hoist_inline_string_enum(
3759                                        items_schema,
3760                                        values,
3761                                        format!("{parent_schema_name}Item"),
3762                                        dependencies,
3763                                    ),
3764                                    None => SchemaType::Primitive {
3765                                        rust_type: "String".to_string(),
3766                                        serde_with: None,
3767                                    },
3768                                }
3769                            }
3770                            OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
3771                                let details = items_schema.details();
3772                                let rust_type = self.get_number_rust_type(inferred, details);
3773                                SchemaType::Primitive {
3774                                    rust_type,
3775                                    serde_with: None,
3776                                }
3777                            }
3778                            OpenApiSchemaType::Boolean => SchemaType::Primitive {
3779                                rust_type: "bool".to_string(),
3780                                serde_with: None,
3781                            },
3782                            _ => SchemaType::Primitive {
3783                                rust_type: "serde_json::Value".to_string(),
3784                                serde_with: None,
3785                            },
3786                        }
3787                    } else {
3788                        SchemaType::Primitive {
3789                            rust_type: "serde_json::Value".to_string(),
3790                            serde_with: None,
3791                        }
3792                    }
3793                }
3794                _ => SchemaType::Primitive {
3795                    rust_type: "serde_json::Value".to_string(),
3796                    serde_with: None,
3797                },
3798            };
3799
3800            Ok(SchemaType::Array {
3801                item_type: Box::new(item_type),
3802            })
3803        } else {
3804            // No items specified, fall back to generic array
3805            Ok(SchemaType::Primitive {
3806                rust_type: "Vec<serde_json::Value>".to_string(),
3807                serde_with: None,
3808            })
3809        }
3810    }
3811
3812    fn get_number_rust_type(
3813        &self,
3814        schema_type: OpenApiSchemaType,
3815        details: &crate::openapi::SchemaDetails,
3816    ) -> String {
3817        // Q2.0: delegate to the TypeMapper chokepoint. The fallback for
3818        // non-numeric inputs is preserved for backwards compatibility
3819        // (callers in 2025-era code path `Integer | Number` here).
3820        let format = details.format.as_deref();
3821        match schema_type {
3822            OpenApiSchemaType::Integer => self.type_mapper.integer_format(format).rust_type,
3823            OpenApiSchemaType::Number => self.type_mapper.number_format(format).rust_type,
3824            _ => self.type_mapper.dynamic_json().rust_type,
3825        }
3826    }
3827
3828    fn analyze_anyof_union(
3829        &mut self,
3830        any_of_schemas: &[Schema],
3831        discriminator: Option<&Discriminator>,
3832        dependencies: &mut HashSet<String>,
3833        context_name: &str,
3834    ) -> Result<SchemaType> {
3835        // Drop {"type": "null"} variants. Nullability is surfaced as Option<T>
3836        // at the property level via is_nullable_pattern(); leaving the null
3837        // variant in here would produce a phantom `()` or `serde_json::Value`
3838        // type alias that the generator can't render.
3839        let filtered_owned: Vec<Schema>;
3840        let any_of_schemas: &[Schema] = if any_of_schemas
3841            .iter()
3842            .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
3843        {
3844            filtered_owned = any_of_schemas
3845                .iter()
3846                .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
3847                .cloned()
3848                .collect();
3849            if filtered_owned.is_empty() {
3850                return Ok(SchemaType::Primitive {
3851                    rust_type: "serde_json::Value".to_string(),
3852                    serde_with: None,
3853                });
3854            }
3855            if filtered_owned.len() == 1 {
3856                return self
3857                    .analyze_schema_value(&filtered_owned[0], context_name)
3858                    .map(|a| a.schema_type);
3859            }
3860            &filtered_owned
3861        } else {
3862            any_of_schemas
3863        };
3864
3865        // Pattern 2: Multiple complex types or mixed primitive/complex = flexible union
3866        let has_refs = any_of_schemas.iter().any(|s| s.is_reference());
3867        let has_objects = any_of_schemas.iter().any(|s| {
3868            matches!(s.schema_type(), Some(OpenApiSchemaType::Object))
3869                || s.inferred_type() == Some(OpenApiSchemaType::Object)
3870        });
3871        let has_arrays = any_of_schemas
3872            .iter()
3873            .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Array)));
3874
3875        // Handle mixed primitive and complex types (like string + array of objects)
3876        // Skip this pattern if all schemas are strings or const values (handle in pattern 3)
3877        let all_string_like = any_of_schemas.iter().all(|s| {
3878            matches!(s.schema_type(), Some(OpenApiSchemaType::String))
3879                || s.details().const_value.is_some()
3880        });
3881
3882        if (has_refs || has_objects || has_arrays || any_of_schemas.len() > 1) && !all_string_like {
3883            // Check if this is a discriminated union
3884            if let Some(disc) = discriminator {
3885                // This is a discriminated anyOf union, analyze it the same way as oneOf
3886                return self.analyze_oneof_union(
3887                    any_of_schemas,
3888                    Some(disc),
3889                    context_name,
3890                    dependencies,
3891                );
3892            }
3893
3894            // Auto-detect implicit discriminator from const fields across all variants
3895            if let Some(disc_field) = self.detect_discriminator_field(any_of_schemas) {
3896                return self.analyze_oneof_union(
3897                    any_of_schemas,
3898                    Some(&Discriminator {
3899                        property_name: disc_field,
3900                        mapping: None,
3901                        default_mapping: None,
3902                        extensions: crate::extensions::Extensions::default(),
3903                    }),
3904                    context_name,
3905                    dependencies,
3906                );
3907            }
3908
3909            // Create an untagged union for flexible matching
3910            let mut variants = Vec::new();
3911
3912            for schema in any_of_schemas {
3913                if let Some(ref_str) = schema.reference() {
3914                    if let Some(target) = self.extract_schema_name(ref_str) {
3915                        dependencies.insert(target.to_string());
3916                        variants.push(SchemaRef {
3917                            target: target.to_string(),
3918                            nullable: false,
3919                        });
3920                    }
3921                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object))
3922                    || schema.inferred_type() == Some(OpenApiSchemaType::Object)
3923                {
3924                    // Generate inline object type for anyOf union
3925                    let inline_index = variants.len();
3926                    let inline_type_name = self.generate_inline_type_name(schema, inline_index);
3927
3928                    // Store inline schema for later analysis and generation
3929                    self.add_inline_schema(&inline_type_name, schema, dependencies)?;
3930
3931                    variants.push(SchemaRef {
3932                        target: inline_type_name,
3933                        nullable: false,
3934                    });
3935                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Array)) {
3936                    // Handle array types in unions by creating a type alias
3937                    let array_type =
3938                        self.analyze_array_schema(schema, context_name, dependencies)?;
3939
3940                    // Create a unique name for this array type in the union
3941                    let array_type_name = if let Some(items_schema) = &schema.details().items {
3942                        if let Some(ref_str) = items_schema.reference() {
3943                            if let Some(item_type_name) = self.extract_schema_name(ref_str) {
3944                                dependencies.insert(item_type_name.to_string());
3945                                format!("{item_type_name}Array")
3946                            } else {
3947                                self.generate_context_aware_name(
3948                                    context_name,
3949                                    "Array",
3950                                    variants.len(),
3951                                    Some(schema),
3952                                )
3953                            }
3954                        } else {
3955                            self.generate_context_aware_name(
3956                                context_name,
3957                                "Array",
3958                                variants.len(),
3959                                Some(schema),
3960                            )
3961                        }
3962                    } else {
3963                        self.generate_context_aware_name(
3964                            context_name,
3965                            "Array",
3966                            variants.len(),
3967                            Some(schema),
3968                        )
3969                    };
3970
3971                    // Store the array as a type alias
3972                    self.resolved_cache.insert(
3973                        array_type_name.clone(),
3974                        AnalyzedSchema {
3975                            name: array_type_name.clone(),
3976                            original: serde_json::to_value(schema).unwrap_or(Value::Null),
3977                            schema_type: array_type,
3978                            dependencies: HashSet::new(),
3979                            nullable: false,
3980                            description: Some("Array variant in union".to_string()),
3981                            default: None,
3982                        },
3983                    );
3984
3985                    // Add array type as a dependency
3986                    dependencies.insert(array_type_name.clone());
3987
3988                    variants.push(SchemaRef {
3989                        target: array_type_name,
3990                        nullable: false,
3991                    });
3992                } else if let Some(schema_type) = schema.schema_type() {
3993                    // Q2.7: when `primitive_unions` is on (default),
3994                    // emit the Rust type directly as the variant
3995                    // target — matches `analyze_untagged_oneof_union`
3996                    // and produces a clean
3997                    //   #[serde(untagged)] pub enum Foo { String(String), Integer(i64) }
3998                    // Pre-Q2.7 / opt-out emits a type alias per
3999                    // primitive (`pub type FooString = String`) and
4000                    // references the alias in the variant — works
4001                    // but adds noise.
4002                    let primitive_unions = self
4003                        .type_mapper
4004                        .config_shape_primitive_unions()
4005                        .unwrap_or(true);
4006
4007                    if primitive_unions {
4008                        let mapped = self.type_mapper.map(schema_type.clone(), schema.details());
4009                        variants.push(SchemaRef {
4010                            target: mapped.rust_type,
4011                            nullable: false,
4012                        });
4013                    } else {
4014                        let inline_index = variants.len();
4015                        let inline_type_name = match schema_type {
4016                            OpenApiSchemaType::String => {
4017                                if inline_index == 0 {
4018                                    format!("{context_name}String")
4019                                } else {
4020                                    format!("{context_name}StringVariant{inline_index}")
4021                                }
4022                            }
4023                            OpenApiSchemaType::Number => {
4024                                if inline_index == 0 {
4025                                    format!("{context_name}Number")
4026                                } else {
4027                                    format!("{context_name}NumberVariant{inline_index}")
4028                                }
4029                            }
4030                            OpenApiSchemaType::Integer => {
4031                                if inline_index == 0 {
4032                                    format!("{context_name}Integer")
4033                                } else {
4034                                    format!("{context_name}IntegerVariant{inline_index}")
4035                                }
4036                            }
4037                            OpenApiSchemaType::Boolean => {
4038                                if inline_index == 0 {
4039                                    format!("{context_name}Boolean")
4040                                } else {
4041                                    format!("{context_name}BooleanVariant{inline_index}")
4042                                }
4043                            }
4044                            _ => format!("{context_name}Variant{inline_index}"),
4045                        };
4046
4047                        let rust_type =
4048                            self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
4049
4050                        self.resolved_cache.insert(
4051                            inline_type_name.clone(),
4052                            AnalyzedSchema {
4053                                name: inline_type_name.clone(),
4054                                original: serde_json::to_value(schema).unwrap_or(Value::Null),
4055                                schema_type: SchemaType::Primitive {
4056                                    rust_type,
4057                                    serde_with: None,
4058                                },
4059                                dependencies: HashSet::new(),
4060                                nullable: false,
4061                                description: schema.details().description.clone(),
4062                                default: None,
4063                            },
4064                        );
4065
4066                        dependencies.insert(inline_type_name.clone());
4067
4068                        variants.push(SchemaRef {
4069                            target: inline_type_name,
4070                            nullable: false,
4071                        });
4072                    }
4073                }
4074            }
4075
4076            if !variants.is_empty() {
4077                return Ok(SchemaType::Union { variants });
4078            }
4079        }
4080
4081        // Pattern 3: String enum pattern (mix of "type": "string" and const values)
4082        let all_strings = any_of_schemas.iter().all(|schema| {
4083            matches!(schema.schema_type(), Some(OpenApiSchemaType::String))
4084                || schema.details().const_value.is_some()
4085        });
4086
4087        if all_strings {
4088            // Collect all constant values as enum variants
4089            let mut enum_values = Vec::new();
4090            let mut has_open_string = false;
4091
4092            for schema in any_of_schemas {
4093                if let Some(const_val) = &schema.details().const_value {
4094                    if let Some(const_str) = const_val.as_str() {
4095                        enum_values.push(const_str.to_string());
4096                    }
4097                } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::String)) {
4098                    has_open_string = true;
4099                }
4100            }
4101
4102            if !enum_values.is_empty() {
4103                if has_open_string {
4104                    // Has both constants and open string - create an extensible enum
4105                    // This generates an enum with known variants plus a Custom(String) variant
4106                    return Ok(SchemaType::ExtensibleEnum {
4107                        known_values: enum_values,
4108                    });
4109                } else {
4110                    // All constants - create string enum
4111                    return Ok(SchemaType::StringEnum {
4112                        values: enum_values,
4113                    });
4114                }
4115            }
4116        }
4117
4118        // Pattern 4: Mixed primitives = fall back to serde_json::Value
4119        Ok(SchemaType::Primitive {
4120            rust_type: "serde_json::Value".to_string(),
4121            serde_with: None,
4122        })
4123    }
4124
4125    /// Find the schema with $recursiveAnchor: true for resolving $recursiveRef: "#"
4126    fn find_recursive_anchor_schema(&self) -> Option<String> {
4127        // Search through all schemas to find one with $recursiveAnchor: true
4128        for (schema_name, schema) in &self.schemas {
4129            let details = schema.details();
4130            if details.recursive_anchor == Some(true) {
4131                return Some(schema_name.clone());
4132            }
4133        }
4134
4135        // If no schema has $recursiveAnchor: true, this might be an older spec
4136        // In that case, $recursiveRef: "#" typically refers to the root schema
4137        // For now, return None to indicate we couldn't resolve it
4138        None
4139    }
4140
4141    /// Detect if a schema should use serde_json::Value for dynamic JSON
4142    /// Based on structural patterns identified in real-world APIs
4143    fn should_use_dynamic_json(&self, schema: &Schema) -> bool {
4144        // Pattern 1: anyOf with [object, null] where object has no properties
4145        if let Schema::AnyOf { any_of, .. } = schema {
4146            if any_of.len() == 2 {
4147                let has_null = any_of
4148                    .iter()
4149                    .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)));
4150                let has_empty_object = any_of.iter().any(|s| self.is_dynamic_object_pattern(s));
4151
4152                if has_null && has_empty_object {
4153                    return true;
4154                }
4155            }
4156        }
4157
4158        // Pattern 2: Direct empty object pattern
4159        self.is_dynamic_object_pattern(schema)
4160    }
4161
4162    /// Check if a schema represents a dynamic object pattern
4163    fn is_dynamic_object_pattern(&self, schema: &Schema) -> bool {
4164        // Must be object type or untyped with object inference
4165        let is_object = match schema.schema_type() {
4166            Some(OpenApiSchemaType::Object) => true,
4167            None => schema.inferred_type() == Some(OpenApiSchemaType::Object),
4168            _ => false,
4169        };
4170
4171        if !is_object {
4172            return false;
4173        }
4174
4175        let details = schema.details();
4176
4177        // If it has explicit additionalProperties, it should remain as a typed object
4178        // that will be generated as BTreeMap<String, serde_json::Value> or similar
4179        if self.has_explicit_additional_properties(schema) {
4180            return false;
4181        }
4182
4183        // Pattern 1: Object with no properties at all (and no additionalProperties)
4184        let no_properties = details
4185            .properties
4186            .as_ref()
4187            .map(|props| props.is_empty())
4188            .unwrap_or(true);
4189
4190        if no_properties {
4191            // Check for constraints that would make this a structured type.
4192            // After J5–J8, these are typed fields rather than `extra` lookups.
4193            let has_structural_constraints = details
4194                .required
4195                .as_ref()
4196                .map(|req| req.iter().any(|r| r != "type"))
4197                .unwrap_or(false)
4198                || details.pattern_properties.is_some()
4199                || details.property_names.is_some()
4200                || details.min_properties.is_some()
4201                || details.max_properties.is_some()
4202                || details.dependent_required.is_some()
4203                || details.dependent_schemas.is_some()
4204                || details.if_schema.is_some()
4205                || details.then_schema.is_some()
4206                || details.else_schema.is_some();
4207
4208            return !has_structural_constraints;
4209        }
4210
4211        false
4212    }
4213
4214    /// Check if this is an object that explicitly allows arbitrary additional properties
4215    fn has_explicit_additional_properties(&self, schema: &Schema) -> bool {
4216        let details = schema.details();
4217
4218        // Check if additionalProperties is explicitly set to true or a schema
4219        matches!(
4220            &details.additional_properties,
4221            Some(crate::openapi::AdditionalProperties::Boolean(true))
4222                | Some(crate::openapi::AdditionalProperties::Schema(_))
4223        )
4224    }
4225
4226    /// Analyze OpenAPI operations to extract request/response schemas
4227    fn analyze_operations(&mut self, analysis: &mut SchemaAnalysis) -> Result<()> {
4228        let spec: crate::openapi::OpenApiSpec = serde_json::from_value(self.openapi_spec.clone())
4229            .map_err(GeneratorError::ParseError)?;
4230        // Operation IDs are emitted into one Rust module, so collision
4231        // detection spans paths and webhooks. Index their canonical Rust type
4232        // names once instead of re-canonicalizing every previously analyzed
4233        // operation for every new endpoint.
4234        let mut canonical_operation_ids = HashSet::new();
4235
4236        if let Some(paths) = &spec.paths {
4237            for (path, path_item) in paths {
4238                // H11: Path Item may be a $ref to components/pathItems. Resolve here.
4239                let resolved = self.resolve_path_item(path_item, &spec)?;
4240                let pi: &crate::openapi::PathItem = resolved.as_ref().unwrap_or(path_item);
4241                self.ingest_path_item_operations(path, pi, analysis, &mut canonical_operation_ids)?;
4242            }
4243        }
4244        // T4: walk webhooks the same way as paths. Per OAS 3.1+, webhooks are
4245        // server→consumer callbacks: their request bodies describe payloads
4246        // the *server* sends *to* the consumer. We currently emit them as
4247        // ordinary operations so their request/response types land in the
4248        // generated client; a future bead may add a typed Webhook enum and
4249        // dispatcher.
4250        if let Some(webhooks) = &spec.webhooks {
4251            for (name, path_item) in webhooks {
4252                let synthetic_path = format!("__webhook__/{name}");
4253                self.ingest_path_item_operations(
4254                    &synthetic_path,
4255                    path_item,
4256                    analysis,
4257                    &mut canonical_operation_ids,
4258                )?;
4259            }
4260        }
4261        Ok(())
4262    }
4263
4264    /// H11: Resolve a Path Item's `$ref` (3.1+ allows them) against
4265    /// `components/pathItems`. Returns Some(resolved) when a ref was followed,
4266    /// or None when the input is already inline.
4267    fn resolve_path_item(
4268        &self,
4269        path_item: &crate::openapi::PathItem,
4270        spec: &crate::openapi::OpenApiSpec,
4271    ) -> Result<Option<crate::openapi::PathItem>> {
4272        let Some(reference) = &path_item.reference else {
4273            return Ok(None);
4274        };
4275        let target_name = reference
4276            .strip_prefix("#/components/pathItems/")
4277            .ok_or_else(|| {
4278                GeneratorError::UnresolvedReference(format!(
4279                    "Path Item $ref must point at #/components/pathItems/{{name}}, got {reference}"
4280                ))
4281            })?;
4282        let pi = spec
4283            .components
4284            .as_ref()
4285            .and_then(|c| c.path_items.as_ref())
4286            .and_then(|map| map.get(target_name))
4287            .ok_or_else(|| {
4288                GeneratorError::UnresolvedReference(format!(
4289                    "Path Item ref {reference} not found in components/pathItems"
4290                ))
4291            })?;
4292        Ok(Some(pi.clone()))
4293    }
4294
4295    fn ingest_path_item_operations(
4296        &mut self,
4297        path: &str,
4298        path_item: &crate::openapi::PathItem,
4299        analysis: &mut SchemaAnalysis,
4300        canonical_operation_ids: &mut HashSet<String>,
4301    ) -> Result<()> {
4302        for (method, operation) in path_item.operations() {
4303            // Generate operation ID if missing.
4304            let raw_operation_id = operation
4305                .operation_id
4306                .clone()
4307                .unwrap_or_else(|| Self::generate_operation_id(method, path));
4308
4309            // T6: detect operationId collisions. Per the OAS spec these MUST
4310            // be unique, but real-world specs (arcade, cal-com, telnyx,
4311            // val-town, …) frequently aren't. Auto-disambiguate by suffixing
4312            // with the method, then a counter, and warn.
4313            //
4314            // The collision key is the PascalCased form so that case-only
4315            // differences (telnyx has `getMdrUsageReports` AND
4316            // `GetMdrUsageReports`) collide too — otherwise codegen would
4317            // produce two `GetMdrUsageReportsApiError` enums in the same
4318            // module.
4319            let operation_id = if canonical_operation_ids
4320                .contains(&Self::canonical_operation_id(&raw_operation_id))
4321            {
4322                let method_lower = method.to_lowercase();
4323                let mut candidate = format!("{}_{}", raw_operation_id, method_lower);
4324                let mut suffix = 2;
4325                while canonical_operation_ids.contains(&Self::canonical_operation_id(&candidate)) {
4326                    candidate = format!("{}_{}_{}", raw_operation_id, method_lower, suffix);
4327                    suffix += 1;
4328                }
4329                eprintln!(
4330                    "⚠️  duplicate operationId `{}` at `{} {}` — disambiguated to `{}`",
4331                    raw_operation_id, method, path, candidate
4332                );
4333                candidate
4334            } else {
4335                raw_operation_id.clone()
4336            };
4337
4338            let op_info = self.analyze_single_operation(
4339                &operation_id,
4340                method,
4341                path,
4342                operation,
4343                path_item.parameters.as_ref(),
4344                analysis,
4345            )?;
4346            analysis
4347                .operation_id_aliases
4348                .entry(raw_operation_id)
4349                .or_default()
4350                .push(operation_id.clone());
4351            canonical_operation_ids.insert(Self::canonical_operation_id(&operation_id));
4352            analysis.operations.insert(operation_id, op_info);
4353        }
4354        Ok(())
4355    }
4356
4357    fn canonical_operation_id(operation_id: &str) -> String {
4358        use heck::ToPascalCase;
4359        operation_id.replace('.', "_").to_pascal_case()
4360    }
4361
4362    /// Generate an operation ID from method and path when not provided
4363    /// Converts paths like "/v0/servers/{serverId}" + "get" to "getV0ServersServerId"
4364    fn generate_operation_id(method: &str, path: &str) -> String {
4365        // Start with the HTTP method in lowercase
4366        let mut operation_id = method.to_lowercase();
4367
4368        // Process the path: remove leading slash, split by /, convert to camelCase
4369        let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
4370
4371        for part in path_parts {
4372            if part.is_empty() {
4373                continue;
4374            }
4375
4376            // Handle path parameters: {serverId} -> ServerId
4377            let cleaned_part = if part.starts_with('{') && part.ends_with('}') {
4378                &part[1..part.len() - 1]
4379            } else {
4380                part
4381            };
4382
4383            // Convert to PascalCase and append
4384            let pascal_case_part = cleaned_part
4385                .split(&['-', '_'][..])
4386                .map(|s| {
4387                    let mut chars = s.chars();
4388                    match chars.next() {
4389                        None => String::new(),
4390                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4391                    }
4392                })
4393                .collect::<String>();
4394
4395            operation_id.push_str(&pascal_case_part);
4396        }
4397
4398        operation_id
4399    }
4400
4401    /// Analyze a single OpenAPI operation
4402    fn analyze_single_operation(
4403        &mut self,
4404        operation_id: &str,
4405        method: &str,
4406        path: &str,
4407        operation: &crate::openapi::Operation,
4408        path_item_parameters: Option<&Vec<crate::openapi::Parameter>>,
4409        _analysis: &mut SchemaAnalysis,
4410    ) -> Result<OperationInfo> {
4411        let raw_path_item = self
4412            .openapi_spec
4413            .get("paths")
4414            .and_then(|paths| paths.get(path))
4415            .cloned();
4416        let raw_operation = raw_path_item
4417            .as_ref()
4418            .and_then(|path_item| path_item.get(method.to_ascii_lowercase()))
4419            .cloned();
4420        let mut op_info = OperationInfo {
4421            operation_id: operation_id.to_string(),
4422            method: method.to_uppercase(),
4423            path: path.to_string(),
4424            summary: operation.summary.clone(),
4425            description: operation.description.clone(),
4426            request_body: None,
4427            // Per OAS 3.x §"Request Body Object", `required` defaults to false.
4428            request_body_required: operation
4429                .request_body
4430                .as_ref()
4431                .and_then(|rb| rb.required)
4432                .unwrap_or(false),
4433            response_schemas: BTreeMap::new(),
4434            parameters: Vec::new(),
4435            supports_streaming: false, // Will be determined by StreamingConfig, not spec
4436            stream_parameter: None,    // Will be determined by StreamingConfig, not spec
4437            tags: operation.tags.clone().unwrap_or_default(),
4438        };
4439
4440        // Extract request body schema with content-type awareness
4441        if let Some(request_body) = &operation.request_body {
4442            use crate::openapi::{is_form_urlencoded_media_type, is_json_media_type};
4443            if let Some((content_type, maybe_schema)) = request_body.best_content() {
4444                op_info.request_body = if is_json_media_type(content_type) {
4445                    match maybe_schema {
4446                        Some(s) => {
4447                            let validation_schema = self
4448                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
4449                                .unwrap_or(
4450                                    serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
4451                                );
4452                            Some(
4453                                self.resolve_or_inline_schema(s, operation_id, "Request")
4454                                    .map(|name| RequestBodyContent::Json {
4455                                        schema_name: name,
4456                                        media_type: content_type.to_string(),
4457                                        validation_schema,
4458                                    })?,
4459                            )
4460                        }
4461                        None => Some(RequestBodyContent::SchemaLess {
4462                            media_type: content_type.to_string(),
4463                        }),
4464                    }
4465                } else if is_form_urlencoded_media_type(content_type) {
4466                    match maybe_schema {
4467                        Some(s) => {
4468                            let validation_schema = self
4469                                .raw_request_body_schema(raw_operation.as_ref(), content_type)
4470                                .unwrap_or(
4471                                    serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
4472                                );
4473                            Some(
4474                                self.resolve_or_inline_schema(s, operation_id, "Request")
4475                                    .map(|name| RequestBodyContent::FormUrlEncoded {
4476                                        schema_name: name,
4477                                        media_type: content_type.to_string(),
4478                                        validation_schema,
4479                                    })?,
4480                            )
4481                        }
4482                        None => Some(RequestBodyContent::SchemaLess {
4483                            media_type: content_type.to_string(),
4484                        }),
4485                    }
4486                } else {
4487                    match content_type {
4488                        "multipart/form-data" => Some(RequestBodyContent::Multipart),
4489                        "application/octet-stream" => Some(RequestBodyContent::OctetStream),
4490                        "text/plain" => Some(RequestBodyContent::TextPlain),
4491                        _ => None,
4492                    }
4493                };
4494            }
4495            if op_info.request_body.is_none() {
4496                let mut media_types = request_body
4497                    .content
4498                    .as_ref()
4499                    .map(|content| content.keys().cloned().collect::<Vec<_>>())
4500                    .unwrap_or_default();
4501                media_types.sort();
4502                if !media_types.is_empty() {
4503                    op_info.request_body = Some(RequestBodyContent::Unsupported { media_types });
4504                }
4505            }
4506        }
4507
4508        // Extract response schemas
4509        if let Some(responses) = &operation.responses {
4510            for (status_code, response) in responses {
4511                // T15: SSE auto-detection. If any response declares
4512                // `text/event-stream`, mark the operation as streaming. The
4513                // user can still override via config; here we lift the spec
4514                // signal so a `stream: true` parameter and an event-stream
4515                // content type produce a streaming variant by default.
4516                if let Some(content) = response.content.as_ref() {
4517                    if content.keys().any(|ct| ct.starts_with("text/event-stream")) {
4518                        op_info.supports_streaming = true;
4519                    }
4520                }
4521
4522                if let Some(schema) = response.json_schema() {
4523                    if let Some(schema_ref) = schema.reference() {
4524                        // Named schema reference
4525                        if let Some(schema_name) = self.extract_schema_name(schema_ref) {
4526                            op_info
4527                                .response_schemas
4528                                .insert(status_code.clone(), schema_name.to_string());
4529                        }
4530                    } else {
4531                        // Inline schema - generate a synthetic type name and analyze it
4532                        let synthetic_name =
4533                            self.generate_inline_response_type_name(operation_id, status_code);
4534
4535                        // Use the existing inline schema infrastructure
4536                        let mut deps = HashSet::new();
4537                        self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
4538
4539                        op_info
4540                            .response_schemas
4541                            .insert(status_code.clone(), synthetic_name);
4542                    }
4543                }
4544            }
4545        }
4546
4547        // T15: detect a `stream` boolean parameter on the operation; pair it
4548        // with the SSE response signal above to populate stream_parameter.
4549        if op_info.supports_streaming
4550            && let Some(parameters) = &operation.parameters
4551        {
4552            for param in parameters {
4553                if let Some(name) = param.name.as_deref() {
4554                    if name.eq_ignore_ascii_case("stream") {
4555                        op_info.stream_parameter = Some(name.to_string());
4556                        break;
4557                    }
4558                }
4559            }
4560        }
4561
4562        // Extract parameters (operation-level first, then merge path-item-level)
4563        if let Some(parameters) = &operation.parameters {
4564            for (index, param) in parameters.iter().enumerate() {
4565                // into_owned: analyze_parameter needs `&mut self` (it may
4566                // register an inline object schema for form-exploded query
4567                // params), which can't coexist with the Cow's `&self` borrow.
4568                let resolved = self.resolve_parameter(param).into_owned();
4569                let validation_schema = raw_operation
4570                    .as_ref()
4571                    .and_then(|operation| operation.get("parameters"))
4572                    .and_then(Value::as_array)
4573                    .and_then(|parameters| parameters.get(index))
4574                    .and_then(|parameter| self.raw_parameter_schema(parameter));
4575                if let Some(param_info) =
4576                    self.analyze_parameter(&resolved, operation_id, validation_schema)?
4577                {
4578                    op_info.parameters.push(param_info);
4579                }
4580            }
4581        }
4582
4583        // Merge path-item-level parameters (operation params take precedence per OpenAPI spec)
4584        if let Some(path_params) = path_item_parameters {
4585            let existing_keys: std::collections::HashSet<(String, String)> = op_info
4586                .parameters
4587                .iter()
4588                .map(|p| (p.name.clone(), p.location.clone()))
4589                .collect();
4590            for (index, param) in path_params.iter().enumerate() {
4591                let resolved = self.resolve_parameter(param).into_owned();
4592                let validation_schema = raw_path_item
4593                    .as_ref()
4594                    .and_then(|path_item| path_item.get("parameters"))
4595                    .and_then(Value::as_array)
4596                    .and_then(|parameters| parameters.get(index))
4597                    .and_then(|parameter| self.raw_parameter_schema(parameter));
4598                if let Some(param_info) =
4599                    self.analyze_parameter(&resolved, operation_id, validation_schema)?
4600                {
4601                    if !existing_keys
4602                        .contains(&(param_info.name.clone(), param_info.location.clone()))
4603                    {
4604                        op_info.parameters.push(param_info);
4605                    }
4606                }
4607            }
4608        }
4609
4610        // Synthesize path parameters that are referenced via `{var}` in the
4611        // path template but not declared as parameters in the spec.
4612        // langsmith/knocklabs/cloudflare hit this — `/repos/{owner}/{repo}/...`
4613        // declares `repo` but not `owner`. Without this, codegen emits
4614        // `format!("/repos/{owner}/...", repo)` and `owner` is undefined
4615        // (E0425). We synthesize each missing variable as a required
4616        // `String` path parameter.
4617        let mut declared_path_names: std::collections::HashSet<String> = op_info
4618            .parameters
4619            .iter()
4620            .filter(|p| p.location == "path")
4621            .map(|p| p.name.clone())
4622            .collect();
4623        let bytes = path.as_bytes().iter();
4624        let mut current = String::new();
4625        let mut in_brace = false;
4626        let mut synthesized: Vec<String> = Vec::new();
4627        for b in bytes {
4628            match *b {
4629                b'{' => {
4630                    in_brace = true;
4631                    current.clear();
4632                }
4633                b'}' if in_brace => {
4634                    in_brace = false;
4635                    if !current.is_empty() && !declared_path_names.contains(&current) {
4636                        synthesized.push(current.clone());
4637                        declared_path_names.insert(current.clone());
4638                    }
4639                }
4640                _ if in_brace => current.push(*b as char),
4641                _ => {}
4642            }
4643        }
4644        for name in synthesized {
4645            eprintln!(
4646                "⚠️  path `{}` references `{{{}}}` but the spec doesn't declare it as a parameter — synthesizing as required String",
4647                path, name
4648            );
4649            op_info.parameters.push(ParameterInfo {
4650                name,
4651                location: "path".to_string(),
4652                required: true,
4653                schema_ref: None,
4654                rust_type: "String".to_string(),
4655                description: None,
4656                enum_values: None,
4657                rust_ident: None,
4658                query_serialization: None,
4659                validation_schema: None,
4660            });
4661        }
4662
4663        // Disambiguate Rust idents across the operation. Real-world specs
4664        // sometimes use both `kebab-case` and `snake_case` for closely-related
4665        // filter parameters (vercel: `exclude_ids` + `exclude-ids`), or
4666        // operator-suffixed forms (twilio: `StartTime`, `StartTime<`,
4667        // `StartTime>`). Without disambiguation those parameters share a
4668        // single binding and the generated body fails E0382 (use of moved
4669        // value) or E0415 (binding declared twice).
4670        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
4671        for p in op_info.parameters.iter_mut() {
4672            let raw = base_param_ident(&p.name);
4673            let mut chosen = raw.clone();
4674            let mut suffix = 2;
4675            while !used.insert(chosen.clone()) {
4676                chosen = format!("{raw}_{suffix}");
4677                suffix += 1;
4678            }
4679            p.rust_ident = Some(chosen);
4680        }
4681
4682        Ok(op_info)
4683    }
4684
4685    /// Generate a type name for an inline response schema.
4686    ///
4687    /// 200 (the canonical success status) keeps the unsuffixed `{Op}Response`
4688    /// name so simple specs and existing snapshots are unchanged. Every other
4689    /// status code is disambiguated by suffix so that multi-response operations
4690    /// (e.g. 200 + 400) don't collide in the schema registry — see issue #8.
4691    fn generate_inline_response_type_name(&self, operation_id: &str, status_code: &str) -> String {
4692        use heck::ToPascalCase;
4693        let base_name = operation_id.replace('.', "_").to_pascal_case();
4694        let suffix = Self::status_code_suffix(status_code);
4695        format!("{}Response{}", base_name, suffix)
4696    }
4697
4698    /// Map an OpenAPI status code key to a suffix for generated type names.
4699    ///
4700    /// "200" → "" (unchanged, the dominant case)
4701    /// "201", "400", "404" → "201", "400", "404"
4702    /// "default" → "Default"
4703    /// "4XX" / "4xx" → "4xx" (lowercased range form)
4704    fn status_code_suffix(status_code: &str) -> String {
4705        match status_code {
4706            "" | "200" => String::new(),
4707            "default" | "Default" => "Default".to_string(),
4708            other if other.chars().all(|c| c.is_ascii_digit()) => other.to_string(),
4709            other => other.to_ascii_lowercase(),
4710        }
4711    }
4712
4713    /// Generate a type name for an inline request body schema
4714    fn generate_inline_request_type_name(&self, operation_id: &str) -> String {
4715        use heck::ToPascalCase;
4716        // Convert operation_id to PascalCase and append Request
4717        // e.g., "session.prompt" -> "SessionPromptRequest"
4718        // e.g., "pty.create" -> "PtyCreateRequest"
4719        let base_name = operation_id.replace('.', "_").to_pascal_case();
4720        format!("{}Request", base_name)
4721    }
4722
4723    /// Resolve a schema reference to a name, or inline it with a synthetic name.
4724    /// `suffix` controls the generated name (e.g. "Request" or "Response").
4725    fn resolve_or_inline_schema(
4726        &mut self,
4727        schema: &crate::openapi::Schema,
4728        operation_id: &str,
4729        suffix: &str,
4730    ) -> Result<String> {
4731        if let Some(schema_ref) = schema.reference()
4732            && let Some(schema_name) = self.extract_schema_name(schema_ref)
4733        {
4734            return Ok(schema_name.to_string());
4735        }
4736        // Inline schema - generate a synthetic type name and analyze it
4737        let synthetic_name = if suffix == "Request" {
4738            self.generate_inline_request_type_name(operation_id)
4739        } else {
4740            self.generate_inline_response_type_name(operation_id, "")
4741        };
4742        let mut deps = HashSet::new();
4743        self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
4744        Ok(synthetic_name)
4745    }
4746
4747    /// Resolve a parameter reference ($ref) to the actual parameter definition.
4748    /// Returns the resolved parameter, or the original if it's not a reference.
4749    fn resolve_parameter<'a>(
4750        &'a self,
4751        param: &'a crate::openapi::Parameter,
4752    ) -> std::borrow::Cow<'a, crate::openapi::Parameter> {
4753        if let Some(ref_str) = param.reference.as_deref() {
4754            if let Some(param_name) = ref_str.strip_prefix("#/components/parameters/") {
4755                if let Some(resolved) = self.component_parameters.get(param_name) {
4756                    return std::borrow::Cow::Borrowed(resolved);
4757                }
4758            }
4759        }
4760        std::borrow::Cow::Borrowed(param)
4761    }
4762
4763    /// Analyze a parameter.
4764    ///
4765    /// `operation_id` is used to generate a unique synthetic enum type name
4766    /// when the parameter's inline schema is a string with `enum` or `const`
4767    /// (e.g. `GetItemTheConstant`). The client generator emits the enum
4768    /// alongside the operation methods. See issue #10 follow-up.
4769    /// Look up `#/components/schemas/{name}` in the raw OpenAPI document and
4770    /// decide whether it's a string with enum values. Used by analyze_parameter
4771    /// (T10). String-enum refs flow through to the codegen-typed parameter
4772    /// path; object refs are typed only when form-exploded (issue #27), and
4773    /// other struct refs stay `String` until deepObject / explode=false
4774    /// serialization is generated (T14).
4775    fn referenced_schema_is_string_enum(&self, name: &str) -> bool {
4776        if self.resolve_cached_schema(name).is_some_and(|schema| {
4777            matches!(
4778                schema.schema_type,
4779                SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. }
4780            )
4781        }) {
4782            return true;
4783        }
4784        let Some(schema_value) = self
4785            .openapi_spec
4786            .get("components")
4787            .and_then(|c| c.get("schemas"))
4788            .and_then(|s| s.get(name))
4789        else {
4790            return false;
4791        };
4792        let is_string_type = schema_value
4793            .get("type")
4794            .and_then(|v| v.as_str())
4795            .map(|s| s == "string")
4796            .unwrap_or(false);
4797        let has_enum_or_const =
4798            schema_value.get("enum").is_some() || schema_value.get("const").is_some();
4799        is_string_type && has_enum_or_const
4800    }
4801
4802    fn resolve_raw_local_reference(&self, value: &Value) -> Option<Value> {
4803        let Some(reference) = value.get("$ref").and_then(Value::as_str) else {
4804            return Some(value.clone());
4805        };
4806        let pointer = reference.strip_prefix('#')?;
4807        self.openapi_spec.pointer(pointer).cloned()
4808    }
4809
4810    fn raw_request_body_schema(
4811        &self,
4812        operation: Option<&Value>,
4813        content_type: &str,
4814    ) -> Option<Value> {
4815        let request_body = operation?.get("requestBody")?;
4816        self.resolve_raw_local_reference(request_body)?
4817            .get("content")?
4818            .get(content_type)?
4819            .get("schema")
4820            .cloned()
4821    }
4822
4823    fn raw_parameter_schema(&self, parameter: &Value) -> Option<Value> {
4824        self.resolve_raw_local_reference(parameter)?
4825            .get("schema")
4826            .cloned()
4827    }
4828
4829    fn analyze_parameter(
4830        &mut self,
4831        param: &crate::openapi::Parameter,
4832        operation_id: &str,
4833        raw_validation_schema: Option<Value>,
4834    ) -> Result<Option<ParameterInfo>> {
4835        use heck::ToPascalCase;
4836
4837        let name = param.name.as_deref().unwrap_or("");
4838        let location = param.location.as_deref().unwrap_or("");
4839        let required = param.required.unwrap_or(false);
4840        let validation_schema = match raw_validation_schema {
4841            Some(schema) => Some(schema),
4842            None => param
4843                .schema
4844                .as_ref()
4845                .map(serde_json::to_value)
4846                .transpose()
4847                .map_err(GeneratorError::ParseError)?,
4848        };
4849
4850        let mut rust_type = "String".to_string();
4851        let mut schema_ref = None;
4852        let mut enum_values: Option<Vec<String>> = None;
4853        let mut query_serialization: Option<QuerySerialization> = None;
4854
4855        // OAS 3.x style/explode resolution for `in: query`. Defaults are
4856        // style=form and — for form only — explode=true, so an object/array
4857        // query parameter with nothing specified is already form-exploded
4858        // per spec (issue #27). deepObject is only defined with explode=true;
4859        // an explicit explode=false there is undefined and keeps the fallback.
4860        let is_query = location == "query";
4861        let form_style = matches!(param.style.as_deref(), None | Some("form"));
4862        let form_exploded = form_style && param.explode.unwrap_or(true);
4863        let deep_object =
4864            param.style.as_deref() == Some("deepObject") && param.explode != Some(false);
4865
4866        let object_serialization = if !is_query {
4867            None
4868        } else if deep_object {
4869            Some(QuerySerialization::DeepObject)
4870        } else if form_exploded {
4871            Some(QuerySerialization::FormExplodedObject)
4872        } else if form_style {
4873            Some(QuerySerialization::FormObject)
4874        } else {
4875            None
4876        };
4877
4878        if let Some(schema) = &param.schema {
4879            if let Some(ref_str) = schema.reference() {
4880                // T10: keep the resolved type when the target is a string-enum
4881                // (then `Display`/`as_str` are emitted, see generate_string_enum).
4882                // Object refs on query params with a generated wire style keep
4883                // the resolved struct type too (T14/issue #27); anything else
4884                // stays on the opaque `String` fallback.
4885                if let Some(name) = self.extract_schema_name(ref_str) {
4886                    if self.referenced_schema_is_string_enum(name) {
4887                        schema_ref = Some(name.to_string());
4888                    } else if object_serialization.is_some()
4889                        && self.referenced_schema_is_object(name)
4890                    {
4891                        schema_ref = Some(name.to_string());
4892                        query_serialization = object_serialization.clone();
4893                    } else if is_query
4894                        && form_style
4895                        && let Some(item_type) = self.referenced_array_param_item_type(name)
4896                    {
4897                        // A parameter may reference a reusable array schema
4898                        // rather than declaring `type: array` inline. Preserve
4899                        // that component as a pruning root while projecting the
4900                        // public parameter type to the same Vec<T> used by
4901                        // inline arrays.
4902                        schema_ref = Some(name.to_string());
4903                        query_serialization = Some(if form_exploded {
4904                            QuerySerialization::FormExplodedArray { item_type }
4905                        } else {
4906                            QuerySerialization::FormArray { item_type }
4907                        });
4908                    }
4909                }
4910            } else if object_serialization.is_some() && Self::schema_is_inline_object(schema) {
4911                // Inline object schema on a query parameter with a generated
4912                // wire style: synthesize a struct (e.g. `FindWidgetsFilter`)
4913                // so the caller passes typed fields instead of a pre-encoded
4914                // string.
4915                let op_pascal = operation_id.replace('.', "_").to_pascal_case();
4916                let param_pascal = name.to_pascal_case();
4917                let synthetic_name = format!("{op_pascal}{param_pascal}");
4918                let mut deps = HashSet::new();
4919                self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
4920                schema_ref = Some(synthetic_name);
4921                query_serialization = object_serialization.clone();
4922            } else if is_query
4923                && form_style
4924                && matches!(
4925                    schema.schema_type(),
4926                    Some(crate::openapi::SchemaType::Array)
4927                )
4928                && let Some(item_type) = self.array_param_item_type(schema)
4929            {
4930                // Typed form-style array (openapi-generator-anu): the client
4931                // takes `Vec<item_type>` and emits repeated (explode=true) or
4932                // comma-joined (explode=false) pairs. `rust_type` deliberately
4933                // stays "String" because the shared query-serialization plan
4934                // is the authoritative Vec<T> projection. Arrays whose items
4935                // don't type (objects, nested arrays) fall through to the
4936                // explicit unsupported shape below.
4937                query_serialization = Some(if form_exploded {
4938                    QuerySerialization::FormExplodedArray { item_type }
4939                } else {
4940                    QuerySerialization::FormArray { item_type }
4941                });
4942            } else if let Some(schema_type) = schema.schema_type() {
4943                // Route integer/number through the same TypeMapper the schema
4944                // property path uses (see analyze_property), so `format: int32`
4945                // yields `i32` and `[type_mappings]`/strategy config applies to
4946                // parameters too. Hardcoding `i64`/`f64` here previously made
4947                // `format` and config impossible to honour for query/path params.
4948                let format = schema.details().format.clone();
4949                rust_type = match schema_type {
4950                    crate::openapi::SchemaType::Boolean => "bool".to_string(),
4951                    crate::openapi::SchemaType::Integer => {
4952                        self.type_mapper.integer_format(format.as_deref()).rust_type
4953                    }
4954                    crate::openapi::SchemaType::Number => {
4955                        self.type_mapper.number_format(format.as_deref()).rust_type
4956                    }
4957                    crate::openapi::SchemaType::String => "String".to_string(),
4958                    _ => "String".to_string(),
4959                };
4960
4961                if matches!(schema_type, crate::openapi::SchemaType::String) {
4962                    let details = schema.details();
4963                    if details.is_string_enum() {
4964                        if let Some(values) = details.string_enum_values() {
4965                            if !values.is_empty() {
4966                                let op_pascal = operation_id.replace('.', "_").to_pascal_case();
4967                                let param_pascal = name.to_pascal_case();
4968                                rust_type = format!("{op_pascal}{param_pascal}");
4969                                enum_values = Some(values);
4970                            }
4971                        }
4972                    }
4973                }
4974            }
4975
4976            if is_query && query_serialization.is_none() {
4977                let referenced_name = schema
4978                    .reference()
4979                    .and_then(|reference| self.extract_schema_name(reference));
4980                let is_object = referenced_name
4981                    .is_some_and(|name| self.referenced_schema_is_object(name))
4982                    || Self::schema_is_inline_object(schema);
4983                let is_array = referenced_name
4984                    .is_some_and(|name| self.referenced_schema_is_array(name))
4985                    || matches!(
4986                        schema.schema_type(),
4987                        Some(crate::openapi::SchemaType::Array)
4988                    );
4989                let is_composed = referenced_name
4990                    .is_some_and(|name| self.referenced_schema_is_composed_query_shape(name));
4991                let reason = if param.style.as_deref() == Some("deepObject")
4992                    && param.explode == Some(false)
4993                {
4994                    Some("style=deepObject with explode=false is undefined by OpenAPI".to_string())
4995                } else if param.style.as_deref() == Some("deepObject") && !is_object {
4996                    Some("style=deepObject is defined only for object query parameters".to_string())
4997                } else if is_object {
4998                    Some(format!(
4999                        "object query parameters do not support style={}",
5000                        param.style.as_deref().unwrap_or("form")
5001                    ))
5002                } else if is_array && form_style {
5003                    Some(
5004                        "form array query parameters require scalar or string-enum items"
5005                            .to_string(),
5006                    )
5007                } else if is_array {
5008                    Some(format!(
5009                        "array query parameters do not yet support style={}",
5010                        param.style.as_deref().unwrap_or("form")
5011                    ))
5012                } else if is_composed {
5013                    Some(
5014                        "composed or union query schemas cannot be projected to an unambiguous flat wire shape"
5015                            .to_string(),
5016                    )
5017                } else {
5018                    None
5019                };
5020                if let Some(reason) = reason {
5021                    query_serialization = Some(QuerySerialization::Unsupported { reason });
5022                }
5023            }
5024        }
5025
5026        Ok(Some(ParameterInfo {
5027            name: name.to_string(),
5028            location: location.to_string(),
5029            required,
5030            schema_ref,
5031            rust_type,
5032            description: param.description.clone(),
5033            enum_values,
5034            rust_ident: None,
5035            query_serialization,
5036            validation_schema,
5037        }))
5038    }
5039
5040    /// Rust item type for a typed array query parameter
5041    /// (openapi-generator-anu). Scalar items map through the TypeMapper;
5042    /// $ref items resolve only when the target is a generated string enum
5043    /// (those emit `Display`, so `item.to_string()` works in the client).
5044    /// Anything else — objects, nested arrays — returns None and the
5045    /// parameter keeps the opaque-string fallback. Inline-enum'd string
5046    /// items stay plain `String`: the op-scoped enum synthesis (issue #10)
5047    /// is wired for scalar params only.
5048    fn array_param_item_type(&self, schema: &crate::openapi::Schema) -> Option<ArrayItemType> {
5049        let items = schema.details().items.as_deref()?;
5050        if let Some(ref_str) = items.reference() {
5051            let name = self.extract_schema_name(ref_str)?;
5052            return self
5053                .referenced_schema_is_string_enum(name)
5054                .then(|| ArrayItemType::EnumRef(name.to_string()));
5055        }
5056        let format = items.details().format.clone();
5057        let scalar = match items.schema_type()? {
5058            crate::openapi::SchemaType::String => "String".to_string(),
5059            crate::openapi::SchemaType::Integer => {
5060                self.type_mapper.integer_format(format.as_deref()).rust_type
5061            }
5062            crate::openapi::SchemaType::Number => {
5063                self.type_mapper.number_format(format.as_deref()).rust_type
5064            }
5065            crate::openapi::SchemaType::Boolean => "bool".to_string(),
5066            _ => return None,
5067        };
5068        Some(ArrayItemType::Scalar(scalar))
5069    }
5070
5071    /// Resolve a reusable component array (including `$ref` aliases) and
5072    /// apply the same item projection as an inline array parameter.
5073    fn referenced_array_param_item_type(&self, name: &str) -> Option<ArrayItemType> {
5074        let schema = self.resolve_cached_schema(name)?;
5075        let SchemaType::Array { item_type } = &schema.schema_type else {
5076            return None;
5077        };
5078        self.analyzed_array_item_type(item_type)
5079    }
5080
5081    fn analyzed_array_item_type(&self, item_type: &SchemaType) -> Option<ArrayItemType> {
5082        match item_type {
5083            SchemaType::Primitive { rust_type, .. } => {
5084                Some(ArrayItemType::Scalar(rust_type.clone()))
5085            }
5086            SchemaType::Reference { target } => {
5087                let resolved = self.resolve_cached_schema(target)?;
5088                matches!(
5089                    resolved.schema_type,
5090                    SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. }
5091                )
5092                .then(|| ArrayItemType::EnumRef(target.clone()))
5093            }
5094            _ => None,
5095        }
5096    }
5097
5098    /// True when a component (following `$ref` aliases) analyzes to an object.
5099    /// Used to decide whether a referenced query parameter can use a typed
5100    /// object serialization plan (issue #27).
5101    fn referenced_schema_is_object(&self, name: &str) -> bool {
5102        self.resolve_cached_schema(name)
5103            .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Object { .. }))
5104    }
5105
5106    fn referenced_schema_is_array(&self, name: &str) -> bool {
5107        self.resolve_cached_schema(name)
5108            .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Array { .. }))
5109    }
5110
5111    fn referenced_schema_is_composed_query_shape(&self, name: &str) -> bool {
5112        self.resolve_cached_schema(name).is_some_and(|schema| {
5113            matches!(
5114                schema.schema_type,
5115                SchemaType::Composition { .. }
5116                    | SchemaType::Union { .. }
5117                    | SchemaType::DiscriminatedUnion { .. }
5118            )
5119        })
5120    }
5121
5122    fn resolve_cached_schema(&self, name: &str) -> Option<&AnalyzedSchema> {
5123        let mut current = name;
5124        let mut visited = HashSet::new();
5125        loop {
5126            if !visited.insert(current) {
5127                return None;
5128            }
5129            let schema = self.resolved_cache.get(current)?;
5130            if let SchemaType::Reference { target } = &schema.schema_type {
5131                current = target;
5132            } else {
5133                return Some(schema);
5134            }
5135        }
5136    }
5137
5138    /// Inline-schema counterpart of [`Self::referenced_schema_is_object`].
5139    fn schema_is_inline_object(schema: &crate::openapi::Schema) -> bool {
5140        match schema.schema_type() {
5141            Some(crate::openapi::SchemaType::Object) => true,
5142            None => schema.details().properties.is_some(),
5143            _ => false,
5144        }
5145    }
5146}