Skip to main content

openapi_to_rust/
analysis.rs

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