Skip to main content

openapi_model_generator/
parser.rs

1use crate::{
2    models::{
3        CompositionModel, EnumModel, Field, Model, ModelType, RequestModel, ResponseModel,
4        TypeAliasModel, UnionModel, UnionType, UnionVariant,
5    },
6    Result,
7};
8use indexmap::IndexMap;
9use openapiv3::{
10    AdditionalProperties, OpenAPI, ReferenceOr, Schema, SchemaKind, StringFormat, Type,
11    VariantOrUnknownOrEmpty,
12};
13use std::collections::HashSet;
14
15const X_RUST_TYPE: &str = "x-rust-type";
16const X_RUST_ATTRS: &str = "x-rust-attrs";
17
18/// Information about a field extracted from OpenAPI schema
19#[derive(Debug)]
20struct FieldInfo {
21    field_type: String,
22    format: String,
23    is_nullable: bool,
24    is_array_ref: bool,
25    description: Option<String>,
26    custom_attrs: Option<Vec<String>>,
27    validation_rules: Option<crate::models::ValidationRules>,
28}
29
30/// Converts camelCase to PascalCase
31/// Example: "createRole" -> "CreateRole", "listRoles" -> "ListRoles", "listRoles-Input" -> "ListRolesInput"
32pub(crate) fn to_pascal_case(input: &str) -> String {
33    input
34        .split(&['-', '_'][..])
35        .filter(|s| !s.is_empty())
36        .map(|s| {
37            let mut chars = s.chars();
38            match chars.next() {
39                Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
40                None => String::new(),
41            }
42        })
43        .collect::<String>()
44}
45
46/// Extracts custom Rust attributes from x-rust-attrs extension
47fn extract_custom_attrs(schema: &Schema) -> Option<Vec<String>> {
48    schema
49        .schema_data
50        .extensions
51        .get(X_RUST_ATTRS)
52        .and_then(|value| {
53            if let Some(arr) = value.as_array() {
54                let attrs: Vec<String> = arr
55                    .iter()
56                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
57                    .collect();
58                if attrs.is_empty() {
59                    None
60                } else {
61                    Some(attrs)
62                }
63            } else {
64                tracing::warn!(
65                    "x-rust-attrs should be an array of strings, got: {:?}",
66                    value
67                );
68                None
69            }
70        })
71}
72
73pub fn parse_openapi(
74    openapi: &OpenAPI,
75) -> Result<(Vec<ModelType>, Vec<RequestModel>, Vec<ResponseModel>)> {
76    let mut models = Vec::new();
77    let mut requests = Vec::new();
78    let mut responses = Vec::new();
79
80    let mut added_models = HashSet::new();
81
82    let empty_schemas = IndexMap::new();
83    let empty_request_bodies = IndexMap::new();
84
85    let (schemas, request_bodies) = if let Some(components) = &openapi.components {
86        (&components.schemas, &components.request_bodies)
87    } else {
88        (&empty_schemas, &empty_request_bodies)
89    };
90
91    // Parse components/schemas
92    if let Some(components) = &openapi.components {
93        for (name, schema) in &components.schemas {
94            let model_types = parse_schema_to_model_type(name, schema, &components.schemas)?;
95            for model_type in model_types {
96                if added_models.insert(model_type.name().to_string()) {
97                    models.push(model_type);
98                }
99            }
100        }
101
102        // Parse components/requestBodies - extract schemas and create models
103        for (name, request_body_ref) in &components.request_bodies {
104            if let ReferenceOr::Item(request_body) = request_body_ref {
105                for media_type in request_body.content.values() {
106                    if let Some(schema) = &media_type.schema {
107                        let model_types =
108                            parse_schema_to_model_type(name, schema, &components.schemas)?;
109                        for model_type in model_types {
110                            if added_models.insert(model_type.name().to_string()) {
111                                models.push(model_type);
112                            }
113                        }
114                    }
115                }
116            }
117        }
118    }
119
120    // Parse paths
121    for (_path, path_item) in openapi.paths.iter() {
122        let path_item = match path_item {
123            ReferenceOr::Item(item) => item,
124            ReferenceOr::Reference { .. } => continue,
125        };
126
127        let operations = [
128            &path_item.get,
129            &path_item.post,
130            &path_item.put,
131            &path_item.delete,
132            &path_item.patch,
133        ];
134
135        for op in operations.iter().filter_map(|o| o.as_ref()) {
136            let inline_models =
137                process_operation(op, &mut requests, &mut responses, schemas, request_bodies)?;
138            for model_type in inline_models {
139                if added_models.insert(model_type.name().to_string()) {
140                    models.push(model_type);
141                }
142            }
143        }
144    }
145
146    Ok((models, requests, responses))
147}
148
149fn process_operation(
150    operation: &openapiv3::Operation,
151    requests: &mut Vec<RequestModel>,
152    responses: &mut Vec<ResponseModel>,
153    all_schemas: &IndexMap<String, ReferenceOr<Schema>>,
154    request_bodies: &IndexMap<String, ReferenceOr<openapiv3::RequestBody>>,
155) -> Result<Vec<ModelType>> {
156    let mut inline_models = Vec::new();
157
158    // Parse request body
159    if let Some(request_body_ref) = &operation.request_body {
160        let (request_body_data, is_inline) = match request_body_ref {
161            ReferenceOr::Item(request_body) => (Some((request_body, request_body.required)), true),
162            ReferenceOr::Reference { reference } => {
163                if let Some(rb_name) = reference.strip_prefix("#/components/requestBodies/") {
164                    (
165                        request_bodies.get(rb_name).and_then(|rb_ref| match rb_ref {
166                            ReferenceOr::Item(rb) => Some((rb, false)),
167                            ReferenceOr::Reference { .. } => None,
168                        }),
169                        false,
170                    )
171                } else {
172                    (None, false)
173                }
174            }
175        };
176
177        if let Some((request_body, is_required)) = request_body_data {
178            for (content_type, media_type) in &request_body.content {
179                if let Some(schema) = &media_type.schema {
180                    let operation_name =
181                        to_pascal_case(operation.operation_id.as_deref().unwrap_or("Unknown"));
182
183                    let schema_type = if is_inline {
184                        if let ReferenceOr::Item(schema_item) = schema {
185                            if matches!(schema_item.schema_kind, SchemaKind::Type(Type::Object(_)))
186                            {
187                                let model_name = format!("{operation_name}RequestBody");
188                                let model_types =
189                                    parse_schema_to_model_type(&model_name, schema, all_schemas)?;
190                                inline_models.extend(model_types);
191                                model_name
192                            } else {
193                                extract_type_and_format(schema, all_schemas)?.0
194                            }
195                        } else {
196                            extract_type_and_format(schema, all_schemas)?.0
197                        }
198                    } else {
199                        extract_type_and_format(schema, all_schemas)?.0
200                    };
201
202                    let request = RequestModel {
203                        name: format!("{operation_name}Request"),
204                        content_type: content_type.clone(),
205                        schema: schema_type,
206                        is_required,
207                    };
208                    requests.push(request);
209                }
210            }
211        }
212    }
213
214    // Parse responses
215    for (status, response_ref) in operation.responses.responses.iter() {
216        if let ReferenceOr::Item(response) = response_ref {
217            for (content_type, media_type) in &response.content {
218                if let Some(schema) = &media_type.schema {
219                    let response = ResponseModel {
220                        name: format!(
221                            "{}Response",
222                            to_pascal_case(operation.operation_id.as_deref().unwrap_or("Unknown"))
223                        ),
224                        status_code: status.to_string(),
225                        content_type: content_type.clone(),
226                        schema: extract_type_and_format(schema, all_schemas)?.0,
227                        description: Some(response.description.clone()),
228                    };
229                    responses.push(response);
230                }
231            }
232        }
233    }
234    Ok(inline_models)
235}
236
237fn parse_schema_to_model_type(
238    name: &str,
239    schema: &ReferenceOr<Schema>,
240    all_schemas: &IndexMap<String, ReferenceOr<Schema>>,
241) -> Result<Vec<ModelType>> {
242    match schema {
243        ReferenceOr::Reference { .. } => Ok(Vec::new()),
244        ReferenceOr::Item(schema) => {
245            if let Some(rust_type) = schema.schema_data.extensions.get(X_RUST_TYPE) {
246                if let Some(type_str) = rust_type.as_str() {
247                    return Ok(vec![ModelType::TypeAlias(TypeAliasModel {
248                        name: to_pascal_case(name),
249                        target_type: type_str.to_string(),
250                        description: schema.schema_data.description.clone(),
251                        custom_attrs: extract_custom_attrs(schema),
252                    })]);
253                }
254            }
255
256            match &schema.schema_kind {
257                // regular objects
258                SchemaKind::Type(Type::Object(obj)) => {
259                    // Special case: object with only additionalProperties (no regular properties)
260                    if obj.properties.is_empty() && obj.additional_properties.is_some() {
261                        let hashmap_type = match &obj.additional_properties {
262                            Some(additional_props) => match additional_props {
263                                openapiv3::AdditionalProperties::Any(_) => {
264                                    "std::collections::HashMap<String, serde_json::Value>"
265                                        .to_string()
266                                }
267                                openapiv3::AdditionalProperties::Schema(schema_ref) => {
268                                    let (inner_type, _) =
269                                        extract_type_and_format(schema_ref, all_schemas)?;
270                                    format!("std::collections::HashMap<String, {inner_type}>")
271                                }
272                            },
273                            None => {
274                                "std::collections::HashMap<String, serde_json::Value>".to_string()
275                            }
276                        };
277                        return Ok(vec![ModelType::TypeAlias(TypeAliasModel {
278                            name: to_pascal_case(name),
279                            target_type: hashmap_type,
280                            description: schema.schema_data.description.clone(),
281                            custom_attrs: extract_custom_attrs(schema),
282                        })]);
283                    }
284
285                    let mut fields = Vec::new();
286                    let mut inline_models = Vec::new();
287
288                    // Process regular properties
289                    for (field_name, field_schema) in &obj.properties {
290                        if let ReferenceOr::Item(boxed_schema) = field_schema {
291                            if matches!(boxed_schema.schema_kind, SchemaKind::Type(Type::Object(_)))
292                            {
293                                let struct_name = to_pascal_case(field_name);
294                                let wrapped_schema = ReferenceOr::Item((**boxed_schema).clone());
295                                let nested_models = parse_schema_to_model_type(
296                                    &struct_name,
297                                    &wrapped_schema,
298                                    all_schemas,
299                                )?;
300                                inline_models.extend(nested_models);
301                            }
302                        }
303
304                        let (field_info, inline_model) = match field_schema {
305                            ReferenceOr::Item(boxed_schema) => extract_field_info(
306                                name,
307                                field_name,
308                                &ReferenceOr::Item((**boxed_schema).clone()),
309                                all_schemas,
310                            )?,
311                            ReferenceOr::Reference { reference } => extract_field_info(
312                                name,
313                                field_name,
314                                &ReferenceOr::Reference {
315                                    reference: reference.clone(),
316                                },
317                                all_schemas,
318                            )?,
319                        };
320                        if let Some(inline_model) = inline_model {
321                            inline_models.push(inline_model);
322                        }
323                        let is_required = obj.required.contains(field_name);
324                        fields.push(Field {
325                            name: field_name.clone(),
326                            field_type: field_info.field_type,
327                            format: field_info.format,
328                            is_required,
329                            is_array_ref: field_info.is_array_ref,
330                            is_nullable: field_info.is_nullable,
331                            description: field_info.description,
332                            custom_attrs: field_info.custom_attrs,
333                            validation_rules: field_info.validation_rules,
334                        });
335                    }
336
337                    let mut models = inline_models;
338                    if obj.properties.is_empty() && obj.additional_properties.is_none() {
339                        models.push(ModelType::Struct(Model {
340                            name: to_pascal_case(name),
341                            fields: vec![],
342                            custom_attrs: extract_custom_attrs(schema),
343                            description: schema.schema_data.description.clone(),
344                        }));
345                    } else if !fields.is_empty() {
346                        models.push(ModelType::Struct(Model {
347                            name: to_pascal_case(name),
348                            fields,
349                            custom_attrs: extract_custom_attrs(schema),
350                            description: schema.schema_data.description.clone(),
351                        }));
352                    }
353                    Ok(models)
354                }
355
356                // allOf
357                SchemaKind::AllOf { all_of } => {
358                    let (all_fields, inline_models) =
359                        resolve_all_of_fields(name, all_of, all_schemas)?;
360                    let mut models = inline_models;
361
362                    if !all_fields.is_empty() {
363                        models.push(ModelType::Composition(CompositionModel {
364                            name: to_pascal_case(name),
365                            all_fields,
366                            custom_attrs: extract_custom_attrs(schema),
367                        }));
368                    }
369
370                    Ok(models)
371                }
372
373                // oneOf
374                SchemaKind::OneOf { one_of } => {
375                    let (variants, inline_models) =
376                        resolve_union_variants(name, one_of, all_schemas)?;
377                    let mut models = inline_models;
378
379                    models.push(ModelType::Union(UnionModel {
380                        name: to_pascal_case(name),
381                        variants,
382                        union_type: UnionType::OneOf,
383                        custom_attrs: extract_custom_attrs(schema),
384                    }));
385
386                    Ok(models)
387                }
388
389                // anyOf
390                SchemaKind::AnyOf { any_of } => {
391                    let (variants, inline_models) =
392                        resolve_union_variants(name, any_of, all_schemas)?;
393                    let mut models = inline_models;
394
395                    models.push(ModelType::Union(UnionModel {
396                        name: to_pascal_case(name),
397                        variants,
398                        union_type: UnionType::AnyOf,
399                        custom_attrs: extract_custom_attrs(schema),
400                    }));
401
402                    Ok(models)
403                }
404
405                // enum strings
406                SchemaKind::Type(Type::String(string_type)) => {
407                    if !string_type.enumeration.is_empty() {
408                        let variants: Vec<String> = string_type
409                            .enumeration
410                            .iter()
411                            .filter_map(|value| value.clone())
412                            .collect();
413
414                        if !variants.is_empty() {
415                            let models = vec![ModelType::Enum(EnumModel {
416                                name: to_pascal_case(name),
417                                variants,
418                                description: schema.schema_data.description.clone(),
419                                custom_attrs: extract_custom_attrs(schema),
420                            })];
421
422                            return Ok(models);
423                        }
424                    }
425                    Ok(Vec::new())
426                }
427
428                SchemaKind::Type(Type::Array(array)) => {
429                    let mut models = Vec::new();
430                    let array_name = to_pascal_case(name);
431
432                    let items = match &array.items {
433                        Some(items) => items,
434                        None => return Ok(Vec::new()),
435                    };
436
437                    match items {
438                        ReferenceOr::Item(item_schema) => match &item_schema.schema_kind {
439                            SchemaKind::OneOf { one_of } => {
440                                let item_type_name = format!("{array_name}Item");
441
442                                let (variants, inline_models) =
443                                    resolve_union_variants(&item_type_name, one_of, all_schemas)?;
444
445                                models.extend(inline_models);
446
447                                models.push(ModelType::Union(UnionModel {
448                                    name: item_type_name.clone(),
449                                    variants,
450                                    union_type: UnionType::OneOf,
451                                    custom_attrs: extract_custom_attrs(item_schema),
452                                }));
453
454                                models.push(ModelType::TypeAlias(TypeAliasModel {
455                                    name: array_name,
456                                    target_type: format!("Vec<{item_type_name}>"),
457                                    description: schema.schema_data.description.clone(),
458                                    custom_attrs: extract_custom_attrs(schema),
459                                }));
460                            }
461
462                            SchemaKind::Type(Type::String(s)) if !s.enumeration.is_empty() => {
463                                let item_type_name = format!("{array_name}Item");
464
465                                let variants: Vec<String> =
466                                    s.enumeration.iter().filter_map(|v| v.clone()).collect();
467
468                                models.push(ModelType::Enum(EnumModel {
469                                    name: item_type_name.clone(),
470                                    variants,
471                                    description: item_schema.schema_data.description.clone(),
472                                    custom_attrs: extract_custom_attrs(item_schema),
473                                }));
474
475                                models.push(ModelType::TypeAlias(TypeAliasModel {
476                                    name: array_name,
477                                    target_type: format!("Vec<{item_type_name}>"),
478                                    description: schema.schema_data.description.clone(),
479                                    custom_attrs: extract_custom_attrs(schema),
480                                }));
481                            }
482
483                            SchemaKind::Type(Type::Integer(n)) if !n.enumeration.is_empty() => {
484                                let item_type_name = format!("{array_name}Item");
485
486                                let variants: Vec<String> = n
487                                    .enumeration
488                                    .iter()
489                                    .filter_map(|v| v.map(|num| format!("Value{num}")))
490                                    .collect();
491
492                                models.push(ModelType::Enum(EnumModel {
493                                    name: item_type_name.clone(),
494                                    variants,
495                                    description: item_schema.schema_data.description.clone(),
496                                    custom_attrs: extract_custom_attrs(item_schema),
497                                }));
498
499                                models.push(ModelType::TypeAlias(TypeAliasModel {
500                                    name: array_name,
501                                    target_type: format!("Vec<{item_type_name}>"),
502                                    description: schema.schema_data.description.clone(),
503                                    custom_attrs: extract_custom_attrs(schema),
504                                }));
505                            }
506
507                            _ => {
508                                let normalized_items = match items {
509                                    ReferenceOr::Item(boxed_schema) => {
510                                        ReferenceOr::Item((**boxed_schema).clone())
511                                    }
512                                    ReferenceOr::Reference { reference } => {
513                                        ReferenceOr::Reference {
514                                            reference: reference.clone(),
515                                        }
516                                    }
517                                };
518
519                                let (inner_type, _) =
520                                    extract_type_and_format(&normalized_items, all_schemas)?;
521
522                                models.push(ModelType::TypeAlias(TypeAliasModel {
523                                    name: array_name,
524                                    target_type: format!("Vec<{inner_type}>"),
525                                    description: schema.schema_data.description.clone(),
526                                    custom_attrs: extract_custom_attrs(schema),
527                                }));
528                            }
529                        },
530
531                        ReferenceOr::Reference { .. } => {
532                            let normalized_items = match items {
533                                ReferenceOr::Item(boxed_schema) => {
534                                    ReferenceOr::Item((**boxed_schema).clone())
535                                }
536                                ReferenceOr::Reference { reference } => ReferenceOr::Reference {
537                                    reference: reference.clone(),
538                                },
539                            };
540
541                            let (inner_type, _) =
542                                extract_type_and_format(&normalized_items, all_schemas)?;
543
544                            models.push(ModelType::TypeAlias(TypeAliasModel {
545                                name: array_name,
546                                target_type: format!("Vec<{inner_type}>"),
547                                description: schema.schema_data.description.clone(),
548                                custom_attrs: extract_custom_attrs(schema),
549                            }));
550                        }
551                    }
552
553                    Ok(models)
554                }
555
556                _ => Ok(Vec::new()),
557            }
558        }
559    }
560}
561
562fn extract_validation_rules(schema: &Schema) -> Option<crate::models::ValidationRules> {
563    use crate::models::ValidationRules;
564
565    let mut rules = ValidationRules::default();
566
567    // Extract type-specific validation rules
568    match &schema.schema_kind {
569        openapiv3::SchemaKind::Type(openapiv3::Type::String(string_type)) => {
570            // Extract format-based validation rules for strings
571            match &string_type.format {
572                openapiv3::VariantOrUnknownOrEmpty::Item(_fmt) => {
573                    // Standard formats like DateTime, Date are handled elsewhere
574                    // For email/url validation, rely on Unknown format strings
575                }
576                openapiv3::VariantOrUnknownOrEmpty::Unknown(unknown_format) => {
577                    match unknown_format.to_lowercase().as_str() {
578                        "email" => rules.email = true,
579                        "uri" | "url" => rules.url = true,
580                        _ => {}
581                    }
582                }
583                _ => {}
584            }
585
586            // Extract string-specific validation rules
587            rules.min_length = string_type.min_length;
588            rules.max_length = string_type.max_length;
589            rules.pattern = string_type.pattern.clone();
590        }
591        openapiv3::SchemaKind::Type(openapiv3::Type::Integer(integer_type)) => {
592            rules.minimum = integer_type.minimum.map(|v| v as f64);
593            rules.maximum = integer_type.maximum.map(|v| v as f64);
594            rules.exclusive_minimum = integer_type.exclusive_minimum;
595            rules.exclusive_maximum = integer_type.exclusive_maximum;
596            rules.multiple_of = integer_type.multiple_of.map(|v| v as f64);
597        }
598        openapiv3::SchemaKind::Type(openapiv3::Type::Number(number_type)) => {
599            rules.minimum = number_type.minimum;
600            rules.maximum = number_type.maximum;
601            rules.exclusive_minimum = number_type.exclusive_minimum;
602            rules.exclusive_maximum = number_type.exclusive_maximum;
603            rules.multiple_of = number_type.multiple_of;
604        }
605        openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) => {
606            rules.min_items = array_type.min_items;
607            rules.max_items = array_type.max_items;
608            rules.unique_items = array_type.unique_items;
609        }
610        _ => {}
611    }
612
613    if rules.has_any() {
614        Some(rules)
615    } else {
616        None
617    }
618}
619
620fn extract_type_and_format(
621    schema: &ReferenceOr<Schema>,
622    all_schemas: &IndexMap<String, ReferenceOr<Schema>>,
623) -> Result<(String, String)> {
624    match schema {
625        ReferenceOr::Reference { reference } => {
626            let type_name = reference.split('/').next_back().unwrap_or("Unknown");
627
628            if let Some(ReferenceOr::Item(schema)) = all_schemas.get(type_name) {
629                if matches!(schema.schema_kind, SchemaKind::OneOf { .. }) {
630                    return Ok((to_pascal_case(type_name), "oneOf".to_string()));
631                }
632            }
633            Ok((to_pascal_case(type_name), "reference".to_string()))
634        }
635
636        ReferenceOr::Item(schema) => match &schema.schema_kind {
637            SchemaKind::Type(Type::String(string_type)) => match &string_type.format {
638                VariantOrUnknownOrEmpty::Item(fmt) => match fmt {
639                    StringFormat::DateTime => {
640                        Ok(("DateTime<Utc>".to_string(), "date-time".to_string()))
641                    }
642                    StringFormat::Date => Ok(("NaiveDate".to_string(), "date".to_string())),
643                    _ => Ok(("String".to_string(), format!("{fmt:?}"))),
644                },
645                VariantOrUnknownOrEmpty::Unknown(unknown_format) => {
646                    if unknown_format.to_lowercase() == "uuid" {
647                        Ok(("Uuid".to_string(), "uuid".to_string()))
648                    } else {
649                        Ok(("String".to_string(), unknown_format.clone()))
650                    }
651                }
652                _ => Ok(("String".to_string(), "string".to_string())),
653            },
654            SchemaKind::Type(Type::Integer(_)) => Ok(("i64".to_string(), "integer".to_string())),
655            SchemaKind::Type(Type::Number(_)) => Ok(("f64".to_string(), "number".to_string())),
656            SchemaKind::Type(Type::Boolean(_)) => Ok(("bool".to_string(), "boolean".to_string())),
657            SchemaKind::Type(Type::Array(arr)) => {
658                if let Some(items) = &arr.items {
659                    match items {
660                        ReferenceOr::Item(boxed_schema) => extract_type_and_format(
661                            &ReferenceOr::Item((**boxed_schema).clone()),
662                            all_schemas,
663                        ),
664
665                        ReferenceOr::Reference { reference } => extract_type_and_format(
666                            &ReferenceOr::Reference {
667                                reference: reference.clone(),
668                            },
669                            all_schemas,
670                        ),
671                    }
672                } else {
673                    Ok(("serde_json::Value".to_string(), "array".to_string()))
674                }
675            }
676            SchemaKind::Type(Type::Object(_obj)) => {
677                Ok(("serde_json::Value".to_string(), "object".to_string()))
678            }
679            _ => Ok(("serde_json::Value".to_string(), "unknown".to_string())),
680        },
681    }
682}
683
684/// Extracts field information including type, format, and nullable flag from OpenAPI schema
685fn extract_field_info(
686    parent_name: &str,
687    field_name: &str,
688    schema: &ReferenceOr<Schema>,
689    all_schemas: &IndexMap<String, ReferenceOr<Schema>>,
690) -> Result<(FieldInfo, Option<ModelType>)> {
691    let (mut field_type, format) = extract_type_and_format(schema, all_schemas)?;
692
693    let (is_nullable, is_array_ref, en, description, custom_attrs, validation_rules) = match schema
694    {
695        ReferenceOr::Reference { reference } => {
696            let is_array_ref = false;
697            let mut is_nullable = false;
698            let mut validation_rules = None;
699
700            if let Some(type_name) = reference.strip_prefix("#/components/schemas/") {
701                if let Some(ReferenceOr::Item(schema)) = all_schemas.get(type_name) {
702                    is_nullable = schema.schema_data.nullable;
703                    // x-rust-attrs on the referenced schema are type-level attributes
704                    // already applied to that type's own definition. Do not propagate
705                    // them to the field that references it.
706                    validation_rules = extract_validation_rules(schema);
707                }
708            }
709
710            (
711                is_nullable,
712                is_array_ref,
713                None,
714                None,
715                None,
716                validation_rules,
717            )
718        }
719
720        ReferenceOr::Item(schema) => {
721            if let Some(rust_type) = schema.schema_data.extensions.get(X_RUST_TYPE) {
722                if let Some(type_str) = rust_type.as_str() {
723                    field_type = type_str.to_string();
724                }
725            }
726
727            let is_nullable = schema.schema_data.nullable;
728            let is_array_ref = matches!(schema.schema_kind, SchemaKind::Type(Type::Array(_)));
729            let description = schema.schema_data.description.clone();
730            let validation_rules = extract_validation_rules(schema);
731
732            let maybe_enum = match &schema.schema_kind {
733                SchemaKind::Type(Type::String(s)) if !s.enumeration.is_empty() => {
734                    let variants: Vec<String> =
735                        s.enumeration.iter().filter_map(|v| v.clone()).collect();
736                    let enum_name = format!("{}{}", parent_name, to_pascal_case(field_name));
737                    field_type = enum_name.clone();
738                    Some(ModelType::Enum(EnumModel {
739                        name: enum_name,
740                        variants,
741                        description: schema.schema_data.description.clone(),
742                        custom_attrs: extract_custom_attrs(schema),
743                    }))
744                }
745                SchemaKind::Type(Type::Object(obj)) => {
746                    if obj.properties.is_empty() {
747                        if let Some(additional_props) = &obj.additional_properties {
748                            match additional_props {
749                                AdditionalProperties::Schema(schema) => {
750                                    let (value_type, _) =
751                                        extract_type_and_format(&schema.clone(), all_schemas)?;
752
753                                    field_type = format!(
754                                        "std::collections::HashMap<String, {}>",
755                                        value_type
756                                    );
757                                }
758
759                                AdditionalProperties::Any(true) => {
760                                    field_type =
761                                        "std::collections::HashMap<String, serde_json::Value>"
762                                            .to_string();
763                                }
764
765                                AdditionalProperties::Any(false) => {
766                                    // technically: no additional props allowed
767                                    field_type = "serde_json::Value".to_string();
768                                }
769                            }
770                            None
771                        } else {
772                            field_type = "serde_json::Value".to_string();
773                            None
774                        }
775                    } else {
776                        let struct_name = to_pascal_case(field_name);
777                        field_type = struct_name.clone();
778
779                        let wrapped_schema = ReferenceOr::Item(schema.clone());
780                        let models =
781                            parse_schema_to_model_type(&struct_name, &wrapped_schema, all_schemas)?;
782
783                        models
784                            .into_iter()
785                            .find(|m| matches!(m, ModelType::Struct(_)))
786                    }
787                }
788                _ => None,
789            };
790            // When an inline enum is generated from this field, x-rust-attrs belong
791            // on the enum type (already captured in the EnumModel above). Don't
792            // also emit them as struct field attributes.
793            let field_custom_attrs = if maybe_enum.is_some() {
794                None
795            } else {
796                extract_custom_attrs(schema)
797            };
798            (
799                is_nullable,
800                is_array_ref,
801                maybe_enum,
802                description,
803                field_custom_attrs,
804                validation_rules,
805            )
806        }
807    };
808
809    Ok((
810        FieldInfo {
811            field_type,
812            format,
813            is_nullable,
814            is_array_ref,
815            description,
816            custom_attrs,
817            validation_rules,
818        },
819        en,
820    ))
821}
822
823fn resolve_all_of_fields(
824    name: &str,
825    all_of: &[ReferenceOr<Schema>],
826    all_schemas: &IndexMap<String, ReferenceOr<Schema>>,
827) -> Result<(Vec<Field>, Vec<ModelType>)> {
828    let mut all_fields: IndexMap<String, Field> = IndexMap::new();
829    let mut models = Vec::new();
830    let mut all_required_fields = HashSet::new();
831
832    for schema_ref in all_of {
833        let schema_to_check = match schema_ref {
834            ReferenceOr::Reference { reference } => reference
835                .strip_prefix("#/components/schemas/")
836                .and_then(|schema_name| all_schemas.get(schema_name)),
837            ReferenceOr::Item(_) => Some(schema_ref),
838        };
839
840        if let Some(ReferenceOr::Item(schema)) = schema_to_check {
841            if let SchemaKind::Type(Type::Object(obj)) = &schema.schema_kind {
842                all_required_fields.extend(obj.required.iter().cloned());
843            }
844        }
845    }
846
847    // Primitive Rust types that a base schema may use as a placeholder. A later
848    // allOf component that re-declares the same field with a different type is
849    // narrowing the field (e.g. a plain `string` base narrowed to a string enum),
850    // so the more specific incoming type should win.
851    const PRIMITIVE_TYPES: &[&str] = &["String", "i64", "f64", "bool"];
852
853    // Try hard to replace all_fields entries that are serde_json::Value or a
854    // primitive placeholder with a more specific type provided by a later allOf
855    // component.
856    // Notes:
857    //  - Most of the substitions are fairly straightforward, Value, Optional Value.
858    //  - HashMap is more complex to understand, we are replacing a Value HashMap
859    //    with an actual structure type
860    //  - A base schema may declare a field as a primitive (e.g. `String`) while a
861    //    composing schema narrows it to a named type (e.g. an inline string enum).
862    //    The named type (more specific) wins.
863    fn less_value(fields: Vec<Field>, all_fields: &mut IndexMap<String, Field>) {
864        for field in fields {
865            if let Some(existing_field) = all_fields.get_mut(&field.name) {
866                // Value
867                if existing_field.field_type == "serde_json::Value" {
868                    *existing_field = field;
869                } else if existing_field.field_type == "Option<serde_json::Value>" {
870                    existing_field.field_type = format!("Option<{}>", field.field_type);
871                // HashMap Value
872                } else if existing_field.field_type
873                    == "std::collections::HashMap<String, serde_json::Value>"
874                {
875                    *existing_field = field;
876                } else if existing_field.field_type
877                    == "Option<std::collections::HashMap<String, serde_json::Value>>"
878                {
879                    existing_field.field_type = format!("Option<{}>", field.field_type);
880                // Vec Value
881                } else if existing_field.field_type == "Vec<serde_json::Value>" {
882                    existing_field.field_type = format!("Vec<{}>", field.field_type);
883                } else if existing_field.field_type == "Option<Vec<serde_json::Value>>" {
884                    existing_field.field_type = format!("Option<Vec<{}>>", field.field_type);
885                // Primitive narrowed to a more specific named type by a later
886                // allOf component (e.g. `String` -> a string enum type).
887                // serde_json::Value is a generic fallback, not a narrowing -
888                // a primitive must not be replaced by something less specific.
889                } else if PRIMITIVE_TYPES.contains(&existing_field.field_type.as_str())
890                    && !PRIMITIVE_TYPES.contains(&field.field_type.as_str())
891                    && field.field_type != "serde_json::Value"
892                {
893                    existing_field.field_type = field.field_type;
894                }
895            } else {
896                all_fields.insert(field.name.clone(), field);
897            }
898        }
899    }
900
901    // Now collect fields from all schemas
902    for schema_ref in all_of {
903        match schema_ref {
904            ReferenceOr::Reference { reference } => {
905                if let Some(schema_name) = reference.strip_prefix("#/components/schemas/") {
906                    if let Some(referenced_schema) = all_schemas.get(schema_name) {
907                        let (fields, inline_models) =
908                            extract_fields_from_schema(name, referenced_schema, all_schemas)?;
909                        // If we have an all_fields entry that is of type serde_json::Value, then we should replace it.
910                        less_value(fields, &mut all_fields);
911                        models.extend(inline_models);
912                    }
913                }
914            }
915            ReferenceOr::Item(_schema) => {
916                let (fields, inline_models) =
917                    extract_fields_from_schema(name, schema_ref, all_schemas)?;
918                // If we have an all_fields entry that is of type serde_json::Value, then we should replace it.
919                less_value(fields, &mut all_fields);
920                models.extend(inline_models);
921            }
922        }
923    }
924
925    // Update is_required for fields based on the merged required set
926    for field in all_fields.values_mut() {
927        if all_required_fields.contains(&field.name) {
928            field.is_required = true;
929        }
930    }
931
932    Ok((all_fields.into_values().collect(), models))
933}
934
935fn resolve_union_variants(
936    name: &str,
937    schemas: &[ReferenceOr<Schema>],
938    all_schemas: &IndexMap<String, ReferenceOr<Schema>>,
939) -> Result<(Vec<UnionVariant>, Vec<ModelType>)> {
940    use std::collections::BTreeSet;
941
942    let mut variants = Vec::new();
943    let mut models = Vec::new();
944    let mut enum_values: BTreeSet<String> = BTreeSet::new();
945    let mut is_all_simple_enum = true;
946
947    for schema_ref in schemas {
948        let resolved = match schema_ref {
949            ReferenceOr::Reference { reference } => reference
950                .strip_prefix("#/components/schemas/")
951                .and_then(|n| all_schemas.get(n)),
952            ReferenceOr::Item(_) => Some(schema_ref),
953        };
954
955        let Some(resolved_schema) = resolved else {
956            is_all_simple_enum = false;
957            continue;
958        };
959
960        match resolved_schema {
961            ReferenceOr::Item(schema) => match &schema.schema_kind {
962                SchemaKind::Type(Type::String(s)) if !s.enumeration.is_empty() => {
963                    enum_values.extend(s.enumeration.iter().filter_map(|v| v.as_ref().cloned()));
964                }
965                SchemaKind::Type(Type::Integer(n)) if !n.enumeration.is_empty() => {
966                    enum_values.extend(
967                        n.enumeration
968                            .iter()
969                            .filter_map(|v| v.map(|num| format!("Value{num}"))),
970                    );
971                }
972
973                _ => is_all_simple_enum = false,
974            },
975            ReferenceOr::Reference { reference } => {
976                if let Some(n) = reference.strip_prefix("#/components/schemas/") {
977                    if let Some(ReferenceOr::Item(inner)) = all_schemas.get(n) {
978                        if let SchemaKind::Type(Type::String(s)) = &inner.schema_kind {
979                            let values: Vec<String> = s
980                                .enumeration
981                                .iter()
982                                .filter_map(|v| v.as_ref().cloned())
983                                .collect();
984                            enum_values.extend(values);
985                        } else {
986                            is_all_simple_enum = false;
987                        }
988                    }
989                }
990            }
991        }
992    }
993    if is_all_simple_enum && !enum_values.is_empty() {
994        let enum_name = to_pascal_case(name);
995        let enum_model = ModelType::Enum(EnumModel {
996            name: enum_name.clone(),
997            variants: enum_values.iter().map(|v| to_pascal_case(v)).collect(),
998            description: None,
999            custom_attrs: None, // Collective enum from multiple schemas, no single source for attrs
1000        });
1001
1002        return Ok((vec![], vec![enum_model]));
1003    }
1004
1005    // fallback for usual union-schemas
1006    for (index, schema_ref) in schemas.iter().enumerate() {
1007        match schema_ref {
1008            ReferenceOr::Reference { reference } => {
1009                if let Some(schema_name) = reference.strip_prefix("#/components/schemas/") {
1010                    if let Some(referenced_schema) = all_schemas.get(schema_name) {
1011                        if let ReferenceOr::Item(schema) = referenced_schema {
1012                            if matches!(schema.schema_kind, SchemaKind::OneOf { .. }) {
1013                                variants.push(UnionVariant {
1014                                    name: to_pascal_case(schema_name),
1015                                    fields: vec![],
1016                                    primitive_type: None,
1017                                });
1018                            } else {
1019                                let (fields, inline_models) = extract_fields_from_schema(
1020                                    schema_name,
1021                                    referenced_schema,
1022                                    all_schemas,
1023                                )?;
1024                                variants.push(UnionVariant {
1025                                    name: to_pascal_case(schema_name),
1026                                    fields,
1027                                    primitive_type: None,
1028                                });
1029                                models.extend(inline_models);
1030                            }
1031                        }
1032                    }
1033                }
1034            }
1035            ReferenceOr::Item(schema) => match &schema.schema_kind {
1036                SchemaKind::Type(Type::String(_)) => {
1037                    variants.push(UnionVariant {
1038                        name: "String".to_string(),
1039                        fields: vec![],
1040                        primitive_type: Some("String".to_string()),
1041                    });
1042                }
1043
1044                SchemaKind::Type(Type::Integer(_)) => {
1045                    variants.push(UnionVariant {
1046                        name: "Integer".to_string(),
1047                        fields: vec![],
1048                        primitive_type: Some("i64".to_string()),
1049                    });
1050                }
1051
1052                SchemaKind::Type(Type::Number(_)) => {
1053                    variants.push(UnionVariant {
1054                        name: "Number".to_string(),
1055                        fields: vec![],
1056                        primitive_type: Some("f64".to_string()),
1057                    });
1058                }
1059
1060                SchemaKind::Type(Type::Boolean(_)) => {
1061                    variants.push(UnionVariant {
1062                        name: "Boolean".to_string(),
1063                        fields: vec![],
1064                        primitive_type: Some("Boolean".to_string()),
1065                    });
1066                }
1067
1068                _ => {
1069                    let (fields, inline_models) =
1070                        extract_fields_from_schema(name, schema_ref, all_schemas)?;
1071                    let variant_name = format!("Variant{index}");
1072                    variants.push(UnionVariant {
1073                        name: variant_name,
1074                        fields,
1075                        primitive_type: None,
1076                    });
1077                    models.extend(inline_models);
1078                }
1079            },
1080        }
1081    }
1082
1083    Ok((variants, models))
1084}
1085
1086fn extract_fields_from_schema(
1087    parent_name: &str,
1088    schema_ref: &ReferenceOr<Schema>,
1089    all_schemas: &IndexMap<String, ReferenceOr<Schema>>,
1090) -> Result<(Vec<Field>, Vec<ModelType>)> {
1091    let mut fields = Vec::new();
1092    let mut inline_models = Vec::new();
1093
1094    match schema_ref {
1095        ReferenceOr::Reference { .. } => Ok((fields, inline_models)),
1096        ReferenceOr::Item(schema) => {
1097            match &schema.schema_kind {
1098                SchemaKind::Type(Type::Object(obj)) => {
1099                    for (field_name, field_schema) in &obj.properties {
1100                        let (field_info, inline_model) = match field_schema {
1101                            ReferenceOr::Item(boxed_schema) => extract_field_info(
1102                                parent_name,
1103                                field_name,
1104                                &ReferenceOr::Item((**boxed_schema).clone()),
1105                                all_schemas,
1106                            )?,
1107                            ReferenceOr::Reference { reference } => extract_field_info(
1108                                parent_name,
1109                                field_name,
1110                                &ReferenceOr::Reference {
1111                                    reference: reference.clone(),
1112                                },
1113                                all_schemas,
1114                            )?,
1115                        };
1116
1117                        let is_nullable = field_info.is_nullable
1118                            || field_name == "value"
1119                            || field_name == "default_value";
1120
1121                        let field_type = field_info.field_type.clone();
1122
1123                        let is_required = obj.required.contains(field_name);
1124                        fields.push(Field {
1125                            name: field_name.clone(),
1126                            field_type,
1127                            format: field_info.format,
1128                            is_required,
1129                            is_nullable,
1130                            is_array_ref: field_info.is_array_ref,
1131                            description: field_info.description,
1132                            custom_attrs: field_info.custom_attrs,
1133                            validation_rules: field_info.validation_rules,
1134                        });
1135                        if let Some(inline_model) = inline_model {
1136                            match &inline_model {
1137                                ModelType::Struct(m) if m.fields.is_empty() => {}
1138                                _ => inline_models.push(inline_model),
1139                            }
1140                        }
1141                    }
1142                }
1143                SchemaKind::Type(Type::String(s)) if !s.enumeration.is_empty() => {
1144                    let name = schema
1145                        .schema_data
1146                        .title
1147                        .clone()
1148                        .unwrap_or_else(|| "AnonymousStringEnum".to_string());
1149
1150                    let enum_model = ModelType::Enum(EnumModel {
1151                        name,
1152                        variants: s
1153                            .enumeration
1154                            .iter()
1155                            .filter_map(|v| v.as_ref().map(|s| to_pascal_case(s)))
1156                            .collect(),
1157                        description: schema.schema_data.description.clone(),
1158                        custom_attrs: extract_custom_attrs(schema),
1159                    });
1160
1161                    inline_models.push(enum_model);
1162                }
1163                SchemaKind::Type(Type::Integer(n)) if !n.enumeration.is_empty() => {
1164                    let name = schema
1165                        .schema_data
1166                        .title
1167                        .clone()
1168                        .unwrap_or_else(|| "AnonymousIntEnum".to_string());
1169
1170                    let enum_model = ModelType::Enum(EnumModel {
1171                        name,
1172                        variants: n
1173                            .enumeration
1174                            .iter()
1175                            .filter_map(|v| v.map(|num| format!("Value{num}")))
1176                            .collect(),
1177                        description: schema.schema_data.description.clone(),
1178                        custom_attrs: extract_custom_attrs(schema),
1179                    });
1180
1181                    inline_models.push(enum_model);
1182                }
1183
1184                _ => {}
1185            }
1186
1187            Ok((fields, inline_models))
1188        }
1189    }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use super::*;
1195    use serde_json::json;
1196
1197    #[test]
1198    fn test_parse_inline_request_body_generates_model() {
1199        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1200            "openapi": "3.0.0",
1201            "info": { "title": "Test API", "version": "1.0.0" },
1202            "paths": {
1203                "/items": {
1204                    "post": {
1205                        "operationId": "createItem",
1206                        "requestBody": {
1207                            "content": {
1208                                "application/json": {
1209                                    "schema": {
1210                                        "type": "object",
1211                                        "properties": {
1212                                            "name": { "type": "string" },
1213                                            "value": { "type": "integer" }
1214                                        },
1215                                        "required": ["name"]
1216                                    }
1217                                }
1218                            }
1219                        },
1220                        "responses": { "200": { "description": "OK" } }
1221                    }
1222                }
1223            }
1224        }))
1225        .expect("Failed to deserialize OpenAPI spec");
1226
1227        let (models, requests, _responses) =
1228            parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1229
1230        // 1. Verify that request model was created
1231        assert_eq!(requests.len(), 1);
1232        let request_model = &requests[0];
1233        assert_eq!(request_model.name, "CreateItemRequest");
1234
1235        // 2. Verify that request schema references a NEW model, not Value
1236        assert_eq!(request_model.schema, "CreateItemRequestBody");
1237
1238        // 3. Verify that the request body model itself was generated
1239        let inline_model = models.iter().find(|m| m.name() == "CreateItemRequestBody");
1240        assert!(
1241            inline_model.is_some(),
1242            "Expected a model named 'CreateItemRequestBody' to be generated"
1243        );
1244
1245        if let Some(ModelType::Struct(model)) = inline_model {
1246            assert_eq!(model.fields.len(), 2);
1247            assert_eq!(model.fields[0].name, "name");
1248            assert_eq!(model.fields[0].field_type, "String");
1249            assert!(model.fields[0].is_required);
1250
1251            assert_eq!(model.fields[1].name, "value");
1252            assert_eq!(model.fields[1].field_type, "i64");
1253            assert!(!model.fields[1].is_required);
1254        } else {
1255            panic!("Expected a Struct model for CreateItemRequestBody");
1256        }
1257    }
1258
1259    #[test]
1260    fn test_parse_ref_request_body_works() {
1261        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1262            "openapi": "3.0.0",
1263            "info": { "title": "Test API", "version": "1.0.0" },
1264            "components": {
1265                "schemas": {
1266                    "ItemData": {
1267                        "type": "object",
1268                        "properties": {
1269                            "name": { "type": "string" }
1270                        }
1271                    }
1272                },
1273                "requestBodies": {
1274                    "CreateItem": {
1275                        "content": {
1276                            "application/json": {
1277                                "schema": { "$ref": "#/components/schemas/ItemData" }
1278                            }
1279                        }
1280                    }
1281                }
1282            },
1283            "paths": {
1284                "/items": {
1285                    "post": {
1286                        "operationId": "createItem",
1287                        "requestBody": { "$ref": "#/components/requestBodies/CreateItem" },
1288                        "responses": { "200": { "description": "OK" } }
1289                    }
1290                }
1291            }
1292        }))
1293        .expect("Failed to deserialize OpenAPI spec");
1294
1295        let (models, requests, _responses) =
1296            parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1297
1298        // Verify that request model was created
1299        assert_eq!(requests.len(), 1);
1300        let request_model = &requests[0];
1301        assert_eq!(request_model.name, "CreateItemRequest");
1302
1303        // Verify that schema references an existing model
1304        assert_eq!(request_model.schema, "ItemData");
1305
1306        // Verify that ItemData model exists in the models list
1307        assert!(models.iter().any(|m| m.name() == "ItemData"));
1308    }
1309
1310    #[test]
1311    fn test_parse_no_request_body() {
1312        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1313            "openapi": "3.0.0",
1314            "info": { "title": "Test API", "version": "1.0.0" },
1315            "paths": {
1316                "/items": {
1317                    "get": {
1318                        "operationId": "listItems",
1319                        "responses": { "200": { "description": "OK" } }
1320                    }
1321                }
1322            }
1323        }))
1324        .expect("Failed to deserialize OpenAPI spec");
1325
1326        let (_models, requests, _responses) =
1327            parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1328
1329        // Verify that no request models were created
1330        assert!(requests.is_empty());
1331    }
1332
1333    #[test]
1334    fn test_nullable_reference_field() {
1335        // Test verifies that nullable is correctly read from the target schema when using $ref
1336        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1337            "openapi": "3.0.0",
1338            "info": { "title": "Test API", "version": "1.0.0" },
1339            "paths": {},
1340            "components": {
1341                "schemas": {
1342                    "NullableUser": {
1343                        "type": "object",
1344                        "nullable": true,
1345                        "properties": {
1346                            "name": { "type": "string" }
1347                        }
1348                    },
1349                    "Post": {
1350                        "type": "object",
1351                        "properties": {
1352                            "author": {
1353                                "$ref": "#/components/schemas/NullableUser"
1354                            }
1355                        }
1356                    }
1357                }
1358            }
1359        }))
1360        .expect("Failed to deserialize OpenAPI spec");
1361
1362        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1363
1364        // Find Post model
1365        let post_model = models.iter().find(|m| m.name() == "Post");
1366        assert!(post_model.is_some(), "Expected Post model to be generated");
1367
1368        if let Some(ModelType::Struct(post)) = post_model {
1369            let author_field = post.fields.iter().find(|f| f.name == "author");
1370            assert!(author_field.is_some(), "Expected author field");
1371
1372            // Verify that nullable is correctly handled for reference type
1373            // (nullable is taken from the target schema NullableUser)
1374            let author = author_field.unwrap();
1375            assert!(
1376                author.is_nullable,
1377                "Expected author field to be nullable (from referenced schema)"
1378            );
1379        } else {
1380            panic!("Expected Post to be a Struct");
1381        }
1382    }
1383
1384    #[test]
1385    fn test_allof_required_fields_merge() {
1386        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1387            "openapi": "3.0.0",
1388            "info": { "title": "Test API", "version": "1.0.0" },
1389            "paths": {},
1390            "components": {
1391                "schemas": {
1392                    "BaseEntity": {
1393                        "type": "object",
1394                        "properties": {
1395                            "id": { "type": "string" },
1396                            "created": { "type": "string" }
1397                        },
1398                        "required": ["id"]
1399                    },
1400                    "Person": {
1401                        "allOf": [
1402                            { "$ref": "#/components/schemas/BaseEntity" },
1403                            {
1404                                "type": "object",
1405                                "properties": {
1406                                    "name": { "type": "string" },
1407                                    "age": { "type": "integer" }
1408                                },
1409                                "required": ["name"]
1410                            }
1411                        ]
1412                    }
1413                }
1414            }
1415        }))
1416        .expect("Failed to deserialize OpenAPI spec");
1417
1418        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1419
1420        // Find Person model
1421        let person_model = models.iter().find(|m| m.name() == "Person");
1422        assert!(
1423            person_model.is_some(),
1424            "Expected Person model to be generated"
1425        );
1426
1427        if let Some(ModelType::Composition(person)) = person_model {
1428            // Verify that id (from BaseEntity) is required
1429            let id_field = person.all_fields.iter().find(|f| f.name == "id");
1430            assert!(id_field.is_some(), "Expected id field");
1431            assert!(
1432                id_field.unwrap().is_required,
1433                "Expected id to be required from BaseEntity"
1434            );
1435
1436            // Verify that name (from second object) is required
1437            let name_field = person.all_fields.iter().find(|f| f.name == "name");
1438            assert!(name_field.is_some(), "Expected name field");
1439            assert!(
1440                name_field.unwrap().is_required,
1441                "Expected name to be required from inline object"
1442            );
1443
1444            // Verify that created and age are not required
1445            let created_field = person.all_fields.iter().find(|f| f.name == "created");
1446            assert!(created_field.is_some(), "Expected created field");
1447            assert!(
1448                !created_field.unwrap().is_required,
1449                "Expected created to be optional"
1450            );
1451
1452            let age_field = person.all_fields.iter().find(|f| f.name == "age");
1453            assert!(age_field.is_some(), "Expected age field");
1454            assert!(
1455                !age_field.unwrap().is_required,
1456                "Expected age to be optional"
1457            );
1458        } else {
1459            panic!("Expected Person to be a Composition");
1460        }
1461    }
1462
1463    #[test]
1464    fn test_x_rust_type_generates_type_alias() {
1465        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1466            "openapi": "3.0.0",
1467            "info": { "title": "Test API", "version": "1.0.0" },
1468            "paths": {},
1469            "components": {
1470                "schemas": {
1471                    "User": {
1472                        "type": "object",
1473                        "x-rust-type": "crate::domain::User",
1474                        "description": "Custom domain user type",
1475                        "properties": {
1476                            "name": { "type": "string" },
1477                            "age": { "type": "integer" }
1478                        }
1479                    }
1480                }
1481            }
1482        }))
1483        .expect("Failed to deserialize OpenAPI spec");
1484
1485        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1486
1487        // Verify that TypeAlias is created, not Struct
1488        let user_model = models.iter().find(|m| m.name() == "User");
1489        assert!(user_model.is_some(), "Expected User model");
1490
1491        match user_model.unwrap() {
1492            ModelType::TypeAlias(alias) => {
1493                assert_eq!(alias.name, "User");
1494                assert_eq!(alias.target_type, "crate::domain::User");
1495                assert_eq!(
1496                    alias.description,
1497                    Some("Custom domain user type".to_string())
1498                );
1499            }
1500            _ => panic!("Expected TypeAlias, got different type"),
1501        }
1502    }
1503
1504    #[test]
1505    fn test_x_rust_type_works_with_enum() {
1506        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1507            "openapi": "3.0.0",
1508            "info": { "title": "Test API", "version": "1.0.0" },
1509            "paths": {},
1510            "components": {
1511                "schemas": {
1512                    "Status": {
1513                        "type": "string",
1514                        "enum": ["active", "inactive"],
1515                        "x-rust-type": "crate::domain::Status"
1516                    }
1517                }
1518            }
1519        }))
1520        .expect("Failed to deserialize OpenAPI spec");
1521
1522        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1523
1524        let status_model = models.iter().find(|m| m.name() == "Status");
1525        assert!(status_model.is_some(), "Expected Status model");
1526
1527        // Should be TypeAlias, not Enum
1528        assert!(
1529            matches!(status_model.unwrap(), ModelType::TypeAlias(_)),
1530            "Expected TypeAlias for enum with x-rust-type"
1531        );
1532    }
1533
1534    #[test]
1535    fn test_x_rust_type_works_with_oneof() {
1536        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1537            "openapi": "3.0.0",
1538            "info": { "title": "Test API", "version": "1.0.0" },
1539            "paths": {},
1540            "components": {
1541                "schemas": {
1542                    "Payment": {
1543                        "oneOf": [
1544                            { "type": "object", "properties": { "card": { "type": "string" } } },
1545                            { "type": "object", "properties": { "cash": { "type": "number" } } }
1546                        ],
1547                        "x-rust-type": "payments::Payment"
1548                    }
1549                }
1550            }
1551        }))
1552        .expect("Failed to deserialize OpenAPI spec");
1553
1554        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1555
1556        let payment_model = models.iter().find(|m| m.name() == "Payment");
1557        assert!(payment_model.is_some(), "Expected Payment model");
1558
1559        // Should be TypeAlias, not Union
1560        match payment_model.unwrap() {
1561            ModelType::TypeAlias(alias) => {
1562                assert_eq!(alias.target_type, "payments::Payment");
1563            }
1564            _ => panic!("Expected TypeAlias for oneOf with x-rust-type"),
1565        }
1566    }
1567
1568    #[test]
1569    fn test_x_rust_attrs_on_struct() {
1570        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1571            "openapi": "3.0.0",
1572            "info": { "title": "Test API", "version": "1.0.0" },
1573            "paths": {},
1574            "components": {
1575                "schemas": {
1576                    "User": {
1577                        "type": "object",
1578                        "x-rust-attrs": [
1579                            "#[derive(Serialize, Deserialize)]",
1580                            "#[serde(rename_all = \"camelCase\")]"
1581                        ],
1582                        "properties": {
1583                            "name": { "type": "string" }
1584                        }
1585                    }
1586                }
1587            }
1588        }))
1589        .expect("Failed to deserialize OpenAPI spec");
1590
1591        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1592
1593        let user_model = models.iter().find(|m| m.name() == "User");
1594        assert!(user_model.is_some(), "Expected User model");
1595
1596        match user_model.unwrap() {
1597            ModelType::Struct(model) => {
1598                assert!(model.custom_attrs.is_some(), "Expected custom_attrs");
1599                let attrs = model.custom_attrs.as_ref().unwrap();
1600                assert_eq!(attrs.len(), 2);
1601                assert_eq!(attrs[0], "#[derive(Serialize, Deserialize)]");
1602                assert_eq!(attrs[1], "#[serde(rename_all = \"camelCase\")]");
1603            }
1604            _ => panic!("Expected Struct model"),
1605        }
1606    }
1607
1608    #[test]
1609    fn test_x_rust_attrs_on_enum() {
1610        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1611            "openapi": "3.0.0",
1612            "info": { "title": "Test API", "version": "1.0.0" },
1613            "paths": {},
1614            "components": {
1615                "schemas": {
1616                    "Status": {
1617                        "type": "string",
1618                        "enum": ["active", "inactive"],
1619                        "x-rust-attrs": ["#[serde(rename_all = \"UPPERCASE\")]"]
1620                    }
1621                }
1622            }
1623        }))
1624        .expect("Failed to deserialize OpenAPI spec");
1625
1626        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1627
1628        let status_model = models.iter().find(|m| m.name() == "Status");
1629        assert!(status_model.is_some(), "Expected Status model");
1630
1631        match status_model.unwrap() {
1632            ModelType::Enum(enum_model) => {
1633                assert!(enum_model.custom_attrs.is_some());
1634                let attrs = enum_model.custom_attrs.as_ref().unwrap();
1635                assert_eq!(attrs.len(), 1);
1636                assert_eq!(attrs[0], "#[serde(rename_all = \"UPPERCASE\")]");
1637            }
1638            _ => panic!("Expected Enum model"),
1639        }
1640    }
1641
1642    #[test]
1643    fn test_x_rust_attrs_with_x_rust_type() {
1644        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1645            "openapi": "3.0.0",
1646            "info": { "title": "Test API", "version": "1.0.0" },
1647            "paths": {},
1648            "components": {
1649                "schemas": {
1650                    "User": {
1651                        "type": "object",
1652                        "x-rust-type": "crate::domain::User",
1653                        "x-rust-attrs": ["#[cfg_attr(test, derive(Default))]"],
1654                        "properties": {
1655                            "name": { "type": "string" }
1656                        }
1657                    }
1658                }
1659            }
1660        }))
1661        .expect("Failed to deserialize OpenAPI spec");
1662
1663        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1664
1665        let user_model = models.iter().find(|m| m.name() == "User");
1666        assert!(user_model.is_some(), "Expected User model");
1667
1668        // Should be TypeAlias with attributes
1669        match user_model.unwrap() {
1670            ModelType::TypeAlias(alias) => {
1671                assert_eq!(alias.target_type, "crate::domain::User");
1672                assert!(alias.custom_attrs.is_some());
1673                let attrs = alias.custom_attrs.as_ref().unwrap();
1674                assert_eq!(attrs.len(), 1);
1675                assert_eq!(attrs[0], "#[cfg_attr(test, derive(Default))]");
1676            }
1677            _ => panic!("Expected TypeAlias with custom attrs"),
1678        }
1679    }
1680
1681    #[test]
1682    fn test_x_rust_attrs_empty_array() {
1683        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1684            "openapi": "3.0.0",
1685            "info": { "title": "Test API", "version": "1.0.0" },
1686            "paths": {},
1687            "components": {
1688                "schemas": {
1689                    "User": {
1690                        "type": "object",
1691                        "x-rust-attrs": [],
1692                        "properties": {
1693                            "name": { "type": "string" }
1694                        }
1695                    }
1696                }
1697            }
1698        }))
1699        .expect("Failed to deserialize OpenAPI spec");
1700
1701        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1702
1703        let user_model = models.iter().find(|m| m.name() == "User");
1704        assert!(user_model.is_some());
1705
1706        match user_model.unwrap() {
1707            ModelType::Struct(model) => {
1708                // Empty array should result in None
1709                assert!(model.custom_attrs.is_none());
1710            }
1711            _ => panic!("Expected Struct"),
1712        }
1713    }
1714
1715    #[test]
1716    fn test_x_rust_type_on_string_property() {
1717        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1718            "openapi": "3.0.0",
1719            "info": { "title": "Test API", "version": "1.0.0" },
1720            "paths": {},
1721            "components": {
1722                "schemas": {
1723                    "Document": {
1724                        "type": "object",
1725                        "description": "Document with custom version type",
1726                        "properties": {
1727                            "title": { "type": "string", "description": "Document title." },
1728                            "content": { "type": "string", "description": "Document content." },
1729                            "version": {
1730                                "type": "string",
1731                                "format": "semver",
1732                                "x-rust-type": "semver::Version",
1733                                "description": "Semantic version."
1734                            }
1735                        },
1736                        "required": ["title", "content", "version"]
1737                    }
1738                }
1739            }
1740        }))
1741        .expect("Failed to deserialize OpenAPI spec");
1742
1743        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1744
1745        let document_model = models.iter().find(|m| m.name() == "Document");
1746        assert!(document_model.is_some(), "Expected Document model");
1747
1748        match document_model.unwrap() {
1749            ModelType::Struct(model) => {
1750                // Verify that version field has custom type
1751                let version_field = model.fields.iter().find(|f| f.name == "version");
1752                assert!(version_field.is_some(), "Expected version field");
1753                assert_eq!(version_field.unwrap().field_type, "semver::Version");
1754
1755                // Verify other fields have regular types
1756                let title_field = model.fields.iter().find(|f| f.name == "title");
1757                assert_eq!(title_field.unwrap().field_type, "String");
1758
1759                let content_field = model.fields.iter().find(|f| f.name == "content");
1760                assert_eq!(content_field.unwrap().field_type, "String");
1761            }
1762            _ => panic!("Expected Struct"),
1763        }
1764    }
1765
1766    #[test]
1767    fn test_x_rust_type_on_integer_property() {
1768        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1769            "openapi": "3.0.0",
1770            "info": { "title": "Test API", "version": "1.0.0" },
1771            "paths": {},
1772            "components": {
1773                "schemas": {
1774                    "Configuration": {
1775                        "type": "object",
1776                        "description": "Configuration with custom duration type",
1777                        "properties": {
1778                            "timeout": {
1779                                "type": "integer",
1780                                "x-rust-type": "std::time::Duration",
1781                                "description": "Timeout duration."
1782                            },
1783                            "retries": { "type": "integer" }
1784                        },
1785                        "required": ["timeout", "retries"]
1786                    }
1787                }
1788            }
1789        }))
1790        .expect("Failed to deserialize OpenAPI spec");
1791
1792        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1793
1794        let config_model = models.iter().find(|m| m.name() == "Configuration");
1795        assert!(config_model.is_some(), "Expected Configuration model");
1796
1797        match config_model.unwrap() {
1798            ModelType::Struct(model) => {
1799                // Verify that timeout field has custom type
1800                let timeout_field = model.fields.iter().find(|f| f.name == "timeout");
1801                assert!(timeout_field.is_some(), "Expected timeout field");
1802                assert_eq!(timeout_field.unwrap().field_type, "std::time::Duration");
1803
1804                // Verify other field has regular i64 type
1805                let retries_field = model.fields.iter().find(|f| f.name == "retries");
1806                assert_eq!(retries_field.unwrap().field_type, "i64");
1807            }
1808            _ => panic!("Expected Struct"),
1809        }
1810    }
1811
1812    #[test]
1813    fn test_x_rust_type_on_number_property() {
1814        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1815            "openapi": "3.0.0",
1816            "info": { "title": "Test API", "version": "1.0.0" },
1817            "paths": {},
1818            "components": {
1819                "schemas": {
1820                    "Product": {
1821                        "type": "object",
1822                        "description": "Product with custom decimal type",
1823                        "properties": {
1824                            "price": {
1825                                "type": "number",
1826                                "x-rust-type": "decimal::Decimal",
1827                                "description": "Product price."
1828                            },
1829                            "quantity": { "type": "number" }
1830                        },
1831                        "required": ["price", "quantity"]
1832                    }
1833                }
1834            }
1835        }))
1836        .expect("Failed to deserialize OpenAPI spec");
1837
1838        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1839
1840        let product_model = models.iter().find(|m| m.name() == "Product");
1841        assert!(product_model.is_some(), "Expected Product model");
1842
1843        match product_model.unwrap() {
1844            ModelType::Struct(model) => {
1845                // Verify that price field has custom type
1846                let price_field = model.fields.iter().find(|f| f.name == "price");
1847                assert!(price_field.is_some(), "Expected price field");
1848                assert_eq!(price_field.unwrap().field_type, "decimal::Decimal");
1849
1850                // Verify other field has regular f64 type
1851                let quantity_field = model.fields.iter().find(|f| f.name == "quantity");
1852                assert_eq!(quantity_field.unwrap().field_type, "f64");
1853            }
1854            _ => panic!("Expected Struct"),
1855        }
1856    }
1857
1858    #[test]
1859    fn test_x_rust_type_on_nullable_property() {
1860        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1861            "openapi": "3.0.0",
1862            "info": { "title": "Test API", "version": "1.0.0" },
1863            "paths": {},
1864            "components": {
1865                "schemas": {
1866                    "Settings": {
1867                        "type": "object",
1868                        "description": "Settings with nullable custom type",
1869                        "properties": {
1870                            "settings": {
1871                                "type": "string",
1872                                "x-rust-type": "serde_json::Value",
1873                                "nullable": true,
1874                                "description": "Optional settings."
1875                            }
1876                        }
1877                    }
1878                }
1879            }
1880        }))
1881        .expect("Failed to deserialize OpenAPI spec");
1882
1883        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1884
1885        let settings_model = models.iter().find(|m| m.name() == "Settings");
1886        assert!(settings_model.is_some(), "Expected Settings model");
1887
1888        match settings_model.unwrap() {
1889            ModelType::Struct(model) => {
1890                let settings_field = model.fields.iter().find(|f| f.name == "settings");
1891                assert!(settings_field.is_some(), "Expected settings field");
1892
1893                let field = settings_field.unwrap();
1894                assert_eq!(field.field_type, "serde_json::Value");
1895                assert!(field.is_nullable, "Expected field to be nullable");
1896            }
1897            _ => panic!("Expected Struct"),
1898        }
1899    }
1900
1901    #[test]
1902    fn test_multiple_properties_with_x_rust_type() {
1903        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1904            "openapi": "3.0.0",
1905            "info": { "title": "Test API", "version": "1.0.0" },
1906            "paths": {},
1907            "components": {
1908                "schemas": {
1909                    "ComplexModel": {
1910                        "type": "object",
1911                        "description": "Model with multiple custom-typed properties",
1912                        "properties": {
1913                            "id": {
1914                                "type": "string",
1915                                "format": "uuid",
1916                                "x-rust-type": "uuid::Uuid"
1917                            },
1918                            "price": {
1919                                "type": "number",
1920                                "x-rust-type": "decimal::Decimal"
1921                            },
1922                            "timeout": {
1923                                "type": "integer",
1924                                "x-rust-type": "std::time::Duration"
1925                            },
1926                            "regular_field": { "type": "string" }
1927                        },
1928                        "required": ["id", "price", "timeout"]
1929                    }
1930                }
1931            }
1932        }))
1933        .expect("Failed to deserialize OpenAPI spec");
1934
1935        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
1936
1937        let model = models.iter().find(|m| m.name() == "ComplexModel");
1938        assert!(model.is_some(), "Expected ComplexModel model");
1939
1940        match model.unwrap() {
1941            ModelType::Struct(struct_model) => {
1942                // Verify all custom types
1943                let id_field = struct_model.fields.iter().find(|f| f.name == "id");
1944                assert_eq!(id_field.unwrap().field_type, "uuid::Uuid");
1945
1946                let price_field = struct_model.fields.iter().find(|f| f.name == "price");
1947                assert_eq!(price_field.unwrap().field_type, "decimal::Decimal");
1948
1949                let timeout_field = struct_model.fields.iter().find(|f| f.name == "timeout");
1950                assert_eq!(timeout_field.unwrap().field_type, "std::time::Duration");
1951
1952                // Verify regular field
1953                let regular_field = struct_model
1954                    .fields
1955                    .iter()
1956                    .find(|f| f.name == "regular_field");
1957                assert_eq!(regular_field.unwrap().field_type, "String");
1958
1959                // Verify nullable flags for required/optional fields
1960                assert!(!id_field.unwrap().is_nullable, "id should not be nullable");
1961                assert!(
1962                    !price_field.unwrap().is_nullable,
1963                    "price should not be nullable"
1964                );
1965                assert!(
1966                    !timeout_field.unwrap().is_nullable,
1967                    "timeout should not be nullable"
1968                );
1969                // regular_field is not in required, but generator doesn't mark it as nullable
1970                // (this is expected behavior - nullable only for explicitly nullable fields)
1971            }
1972            _ => panic!("Expected Struct"),
1973        }
1974    }
1975
1976    #[test]
1977    fn test_x_rust_attrs_on_field() {
1978        let openapi_spec: OpenAPI = serde_json::from_value(json!({
1979            "openapi": "3.0.0",
1980            "info": { "title": "Test API", "version": "1.0.0" },
1981            "paths": {},
1982            "components": {
1983                "schemas": {
1984                    "FrontendEvent": {
1985                        "type": "object",
1986                        "properties": {
1987                            "field": {
1988                                "type": "integer",
1989                                "minimum": 0,
1990                                "maximum": 100,
1991                                "nullable": true,
1992                                "x-rust-attrs": ["#[validate(range(min = 0, max = 100))]"]
1993                            },
1994                            "name": { "type": "string" }
1995                        }
1996                    }
1997                }
1998            }
1999        }))
2000        .expect("Failed to deserialize OpenAPI spec");
2001
2002        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
2003
2004        let model = models.iter().find(|m| m.name() == "FrontendEvent");
2005        assert!(model.is_some(), "Expected FrontendEvent model");
2006
2007        match model.unwrap() {
2008            ModelType::Struct(struct_model) => {
2009                let field = struct_model.fields.iter().find(|f| f.name == "field");
2010                assert!(field.is_some(), "Expected progress_percent field");
2011                let field = field.unwrap();
2012                assert_eq!(field.field_type, "i64");
2013                assert!(
2014                    field.custom_attrs.is_some(),
2015                    "Expected field-level x-rust-attrs"
2016                );
2017                let attrs = field.custom_attrs.as_ref().unwrap();
2018                assert_eq!(attrs.len(), 1);
2019                assert_eq!(attrs[0], "#[validate(range(min = 0, max = 100))]");
2020
2021                let name_field = struct_model.fields.iter().find(|f| f.name == "name");
2022                assert!(name_field.unwrap().custom_attrs.is_none());
2023            }
2024            _ => panic!("Expected Struct"),
2025        }
2026    }
2027
2028    // Two structs each having a field named `type` with different inline enum values.
2029    // The generator must produce two distinct enum types rather than colliding on the
2030    // shared name `Type`.
2031    #[test]
2032    fn test_inline_enum_fields_on_different_structs_get_unique_names() {
2033        let openapi_spec: OpenAPI = serde_json::from_value(json!({
2034            "openapi": "3.0.0",
2035            "info": { "title": "Test API", "version": "1.0.0" },
2036            "paths": {},
2037            "components": {
2038                "schemas": {
2039                    "SignalA": {
2040                        "type": "object",
2041                        "properties": {
2042                            "type": {
2043                                "type": "string",
2044                                "enum": ["variant_a"]
2045                            }
2046                        }
2047                    },
2048                    "SignalB": {
2049                        "type": "object",
2050                        "properties": {
2051                            "type": {
2052                                "type": "string",
2053                                "enum": ["variant_b"]
2054                            }
2055                        }
2056                    }
2057                }
2058            }
2059        }))
2060        .expect("Failed to deserialize OpenAPI spec");
2061
2062        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
2063
2064        // Both enum types must be generated - neither should be dropped by deduplication.
2065        let signal_a_type = models.iter().find(|m| m.name() == "SignalAType");
2066        let signal_b_type = models.iter().find(|m| m.name() == "SignalBType");
2067
2068        assert!(
2069            signal_a_type.is_some(),
2070            "Expected SignalAType enum to be generated"
2071        );
2072        assert!(
2073            signal_b_type.is_some(),
2074            "Expected SignalBType enum to be generated"
2075        );
2076
2077        // Each enum must contain only its own variant.
2078        match signal_a_type.unwrap() {
2079            ModelType::Enum(e) => {
2080                assert_eq!(e.variants, vec!["variant_a"]);
2081            }
2082            _ => panic!("Expected Enum for SignalAType"),
2083        }
2084        match signal_b_type.unwrap() {
2085            ModelType::Enum(e) => {
2086                assert_eq!(e.variants, vec!["variant_b"]);
2087            }
2088            _ => panic!("Expected Enum for SignalBType"),
2089        }
2090
2091        // The struct fields must reference the qualified enum names.
2092        let signal_a = models.iter().find(|m| m.name() == "SignalA");
2093        assert!(signal_a.is_some(), "Expected SignalA struct");
2094        if let Some(ModelType::Struct(s)) = signal_a {
2095            let type_field = s.fields.iter().find(|f| f.name == "type").unwrap();
2096            assert_eq!(type_field.field_type, "SignalAType");
2097        }
2098
2099        let signal_b = models.iter().find(|m| m.name() == "SignalB");
2100        assert!(signal_b.is_some(), "Expected SignalB struct");
2101        if let Some(ModelType::Struct(s)) = signal_b {
2102            let type_field = s.fields.iter().find(|f| f.name == "type").unwrap();
2103            assert_eq!(type_field.field_type, "SignalBType");
2104        }
2105    }
2106
2107    // x-rust-attrs on an inline enum property (e.g. derive macros for the generated
2108    // enum type) must not leak into the parent struct as field-level attributes.
2109    // Before the fix this caused `#[derive(...)]` to appear inside the struct body,
2110    // which is invalid Rust.
2111    #[test]
2112    fn test_x_rust_attrs_on_inline_enum_field_go_to_enum_not_field() {
2113        let openapi_spec: OpenAPI = serde_json::from_value(json!({
2114            "openapi": "3.0.0",
2115            "info": { "title": "Test API", "version": "1.0.0" },
2116            "paths": {},
2117            "components": {
2118                "schemas": {
2119                    "Signal": {
2120                        "type": "object",
2121                        "properties": {
2122                            "kind": {
2123                                "type": "string",
2124                                "enum": ["started", "stopped"],
2125                                "x-rust-attrs": [
2126                                    "#[derive(derive_more::Display, Debug, Clone)]"
2127                                ]
2128                            },
2129                            "name": { "type": "string" }
2130                        }
2131                    }
2132                }
2133            }
2134        }))
2135        .expect("Failed to deserialize OpenAPI spec");
2136
2137        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
2138
2139        // The generated enum must carry the custom attrs.
2140        let kind_enum = models.iter().find(|m| m.name() == "SignalKind");
2141        assert!(
2142            kind_enum.is_some(),
2143            "Expected SignalKind enum to be generated"
2144        );
2145        match kind_enum.unwrap() {
2146            ModelType::Enum(e) => {
2147                assert!(
2148                    e.custom_attrs.is_some(),
2149                    "x-rust-attrs should be on the generated enum"
2150                );
2151                let attrs = e.custom_attrs.as_ref().unwrap();
2152                assert!(attrs.iter().any(|a| a.contains("derive")));
2153            }
2154            _ => panic!("Expected Enum for SignalKind"),
2155        }
2156
2157        // The parent struct field must NOT carry the attrs - emitting derive macros
2158        // as field attributes is invalid Rust.
2159        let signal = models.iter().find(|m| m.name() == "Signal");
2160        assert!(signal.is_some(), "Expected Signal struct");
2161        if let Some(ModelType::Struct(s)) = signal {
2162            let kind_field = s.fields.iter().find(|f| f.name == "kind").unwrap();
2163            assert!(
2164                kind_field.custom_attrs.is_none(),
2165                "x-rust-attrs must not appear on the struct field when they target a generated inline enum"
2166            );
2167        }
2168    }
2169
2170    // A base schema declares fields as plain primitives (String, i64). A composing
2171    // schema narrows those same fields to specific named types via allOf. The
2172    // composed struct must use the more specific types, not the primitive placeholders.
2173    #[test]
2174    fn test_allof_primitive_field_narrowed_to_specific_type() {
2175        let openapi_spec: OpenAPI = serde_json::from_value(json!({
2176            "openapi": "3.0.0",
2177            "info": { "title": "Test API", "version": "1.0.0" },
2178            "paths": {},
2179            "components": {
2180                "schemas": {
2181                    "BaseSignal": {
2182                        "type": "object",
2183                        "required": ["type"],
2184                        "properties": {
2185                            "type": {
2186                                "type": "string",
2187                                "description": "The signal type identifier."
2188                            },
2189                            "name": {
2190                                "type": "string",
2191                                "description": "Human-readable label."
2192                            }
2193                        }
2194                    },
2195                    "ConcreteSignal": {
2196                        "allOf": [
2197                            { "$ref": "#/components/schemas/BaseSignal" },
2198                            {
2199                                "type": "object",
2200                                "required": ["type"],
2201                                "properties": {
2202                                    "type": {
2203                                        "type": "string",
2204                                        "enum": ["concrete"]
2205                                    }
2206                                }
2207                            }
2208                        ]
2209                    }
2210                }
2211            }
2212        }))
2213        .expect("Failed to deserialize OpenAPI spec");
2214
2215        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
2216
2217        // The inline enum for `type` must be generated.
2218        let type_enum = models.iter().find(|m| m.name() == "ConcreteSignalType");
2219        assert!(
2220            type_enum.is_some(),
2221            "Expected ConcreteSignalType enum to be generated"
2222        );
2223        match type_enum.unwrap() {
2224            ModelType::Enum(e) => assert_eq!(e.variants, vec!["concrete"]),
2225            _ => panic!("Expected Enum for ConcreteSignalType"),
2226        }
2227
2228        // The composed struct must use the specific enum type, not plain String.
2229        let concrete = models.iter().find(|m| m.name() == "ConcreteSignal");
2230        assert!(concrete.is_some(), "Expected ConcreteSignal model");
2231        if let Some(ModelType::Composition(c)) = concrete {
2232            let type_field = c.all_fields.iter().find(|f| f.name == "type").unwrap();
2233            assert_eq!(
2234                type_field.field_type, "ConcreteSignalType",
2235                "allOf should narrow plain String to the more specific enum type"
2236            );
2237        } else {
2238            panic!("Expected ConcreteSignal to be a Composition");
2239        }
2240    }
2241
2242    // x-rust-attrs on a $ref target schema are type-level attributes that belong
2243    // to the type definition. They must NOT be propagated to the field that
2244    // references that type, as emitting #[derive(...)] at field position is
2245    // invalid Rust.
2246    #[test]
2247    fn test_x_rust_attrs_from_ref_target_not_propagated_to_field() {
2248        let openapi_spec: OpenAPI = serde_json::from_value(json!({
2249            "openapi": "3.0.0",
2250            "info": { "title": "Test API", "version": "1.0.0" },
2251            "paths": {},
2252            "components": {
2253                "schemas": {
2254                    "Address": {
2255                        "type": "object",
2256                        "x-rust-attrs": ["#[derive(Hash, Eq, PartialEq)]"],
2257                        "properties": {
2258                            "street": { "type": "string" }
2259                        }
2260                    },
2261                    "Person": {
2262                        "type": "object",
2263                        "properties": {
2264                            "name": { "type": "string" },
2265                            "address": { "$ref": "#/components/schemas/Address" }
2266                        }
2267                    }
2268                }
2269            }
2270        }))
2271        .expect("Failed to deserialize OpenAPI spec");
2272
2273        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
2274
2275        let person = models.iter().find(|m| m.name() == "Person");
2276        assert!(person.is_some(), "Expected Person model");
2277
2278        if let Some(ModelType::Struct(s)) = person {
2279            let address_field = s.fields.iter().find(|f| f.name == "address").unwrap();
2280            assert_eq!(address_field.field_type, "Address");
2281            assert!(
2282                address_field.custom_attrs.is_none(),
2283                "x-rust-attrs from the referenced Address type must not appear on the Person.address field"
2284            );
2285        } else {
2286            panic!("Expected Person to be a Struct");
2287        }
2288    }
2289
2290    #[test]
2291    fn test_schema_level_nullable_ref_makes_field_optional() {
2292        // A field whose $ref target schema has `nullable: true` at the schema level
2293        // must produce is_nullable = true, resulting in Option<T> in the output.
2294        let openapi_spec: OpenAPI = serde_json::from_value(serde_json::json!({
2295            "openapi": "3.0.3",
2296            "info": { "title": "Test", "version": "0.1.0" },
2297            "paths": {},
2298            "components": {
2299                "schemas": {
2300                    "NullableString": {
2301                        "type": "string",
2302                        "nullable": true
2303                    },
2304                    "Container": {
2305                        "type": "object",
2306                        "required": ["value"],
2307                        "properties": {
2308                            "value": {
2309                                "$ref": "#/components/schemas/NullableString"
2310                            }
2311                        }
2312                    }
2313                }
2314            }
2315        }))
2316        .expect("Failed to deserialize OpenAPI spec");
2317
2318        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
2319
2320        let container = models.iter().find(|m| m.name() == "Container");
2321        assert!(container.is_some(), "Expected Container model");
2322
2323        if let Some(ModelType::Struct(s)) = container {
2324            let value_field = s.fields.iter().find(|f| f.name == "value").unwrap();
2325            assert!(
2326                value_field.is_nullable,
2327                "Field referencing a nullable schema must be marked is_nullable"
2328            );
2329        } else {
2330            panic!("Expected Container to be a Struct");
2331        }
2332    }
2333
2334    #[test]
2335    fn test_inline_nullable_field_is_optional() {
2336        // An inline field with `nullable: true` directly on the property must be is_nullable.
2337        let openapi_spec: OpenAPI = serde_json::from_value(serde_json::json!({
2338            "openapi": "3.0.3",
2339            "info": { "title": "Test", "version": "0.1.0" },
2340            "paths": {},
2341            "components": {
2342                "schemas": {
2343                    "Widget": {
2344                        "type": "object",
2345                        "properties": {
2346                            "label": {
2347                                "type": "string",
2348                                "nullable": true
2349                            }
2350                        }
2351                    }
2352                }
2353            }
2354        }))
2355        .expect("Failed to deserialize OpenAPI spec");
2356
2357        let (models, _, _) = parse_openapi(&openapi_spec).expect("Failed to parse OpenAPI spec");
2358
2359        let widget = models.iter().find(|m| m.name() == "Widget");
2360        assert!(widget.is_some(), "Expected Widget model");
2361
2362        if let Some(ModelType::Struct(s)) = widget {
2363            let label_field = s.fields.iter().find(|f| f.name == "label").unwrap();
2364            assert!(
2365                label_field.is_nullable,
2366                "Inline field with nullable: true must be marked is_nullable"
2367            );
2368        } else {
2369            panic!("Expected Widget to be a Struct");
2370        }
2371    }
2372}