Skip to main content

openapi_to_rust/
analysis.rs

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