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