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