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