Skip to main content

oapi_codegen/lower/
schema.rs

1//! Lowering OpenAPI schemas into the [`crate::ir`] representation.
2
3use openapiv3::AdditionalProperties;
4use openapiv3::Discriminator;
5use openapiv3::IntegerFormat;
6use openapiv3::IntegerType;
7use openapiv3::ObjectType;
8use openapiv3::ReferenceOr;
9use openapiv3::Schema;
10use openapiv3::SchemaData;
11use openapiv3::SchemaKind;
12use openapiv3::StringFormat;
13use openapiv3::Type;
14use openapiv3::VariantOrUnknownOrEmpty;
15
16use crate::error::Error;
17use crate::error::Result;
18use crate::ir::Alias;
19use crate::ir::Deprecation;
20use crate::ir::Enum;
21use crate::ir::EnumKind;
22use crate::ir::Field;
23use crate::ir::ForeignDerives;
24use crate::ir::IntegerVariant;
25use crate::ir::Item;
26use crate::ir::Module;
27use crate::ir::RustType;
28use crate::ir::StringVariant;
29use crate::ir::Struct;
30use crate::ir::UnionVariant;
31use crate::loader::Spec;
32use crate::loader::ref_target_name;
33use crate::loader::schema_ref_reason;
34use crate::lower::default::lower_default;
35use crate::naming::Case;
36use crate::naming::X_RUST_NAME;
37use crate::naming::to_ident;
38
39/// The `x-rust-type` extension: emit a verbatim Rust type expression.
40pub(crate) const X_RUST_TYPE: &str = "x-rust-type";
41/// The `x-rust-derive` extension: which of `Debug`, `Clone`, `PartialEq` an
42/// `x-rust-type` target implements.
43const X_RUST_DERIVE: &str = "x-rust-derive";
44/// The `x-rust-serde-skip` extension: drop a field via `#[serde(skip)]`.
45const X_RUST_SERDE_SKIP: &str = "x-rust-serde-skip";
46/// The `x-omitempty` extension: force `skip_serializing_if` on/off for a field.
47const X_OMITEMPTY: &str = "x-omitempty";
48/// The `x-order` extension: explicitly order struct fields (1-indexed).
49const X_ORDER: &str = "x-order";
50/// The `x-deprecated-reason` extension: the note for a `#[deprecated]` item.
51const X_DEPRECATED_REASON: &str = "x-deprecated-reason";
52/// The `x-enum-varnames` extension: override generated enum variant identifiers.
53const X_ENUM_VARNAMES: &str = "x-enum-varnames";
54/// The `x-enumNames` extension: alias of [`X_ENUM_VARNAMES`].
55const X_ENUM_NAMES: &str = "x-enumNames";
56
57/// Cap on inline schema nesting the lowering pass will descend before erroring.
58/// Guards against stack exhaustion on pathological or hostile specs. Well above any
59/// realistic hand-written or generated spec, and independent of whatever
60/// recursion limit the YAML/JSON parser happens to enforce.
61const MAX_SCHEMA_DEPTH: usize = 100;
62
63/// Lower every component schema in `spec` into a module of Rust items.
64///
65/// `names` holds the final Rust type name of every schema, from
66/// [`crate::lower::rename::type_renames`]. The caller resolves the names first,
67/// because it also applies them to the service and decides when to report an
68/// unresolved collision. A module built from unchecked `names` can hold two items
69/// with one name, so the caller must check `names` before the emit pass.
70///
71/// One bad schema does not stop the loop. Component schemas are independent of
72/// each other, so a fault in one says nothing about the next. The pass lowers
73/// them all and reports every fault together, and the author makes one pass over
74/// the document instead of one for each fault. A schema that fails contributes no
75/// item, and the module is dropped, because the collected faults end the run.
76pub fn generate_models(spec: &Spec, names: &crate::lower::rename::TypeNames) -> Result<Module> {
77    let renames = names.renames();
78    let mut mapper = Mapper {
79        spec,
80        renames,
81        extra: Vec::new(),
82        depth: 0,
83    };
84    let mut items = Vec::new();
85    let mut diagnostics = crate::lower::validate::Diagnostics::new();
86    for (name, entry) in spec.schemas() {
87        match entry {
88            ReferenceOr::Item(schema) => match mapper.named_to_item(name, schema) {
89                Ok(item) => items.push(item),
90                Err(problem) => diagnostics.push(problem),
91            },
92            ReferenceOr::Reference { reference } => {
93                match mapper.schema_ref_target(reference, "a top-level schema alias") {
94                    Ok(target) => items.push(Item::Alias(Alias {
95                        name: mapper.type_name_ident(name),
96                        doc: None,
97                        deprecated: None,
98                        ty: RustType::Named(target),
99                    })),
100                    Err(problem) => diagnostics.push(problem),
101                }
102            }
103        }
104    }
105    diagnostics.into_result()?;
106    items.append(&mut mapper.extra);
107    let mut module = Module { items };
108    crate::lower::rename::rewrite_module(&mut module, renames);
109    return Ok(module);
110}
111
112/// Lowers schemas into IR items, accumulating hoisted inline types in `extra`.
113struct Mapper<'a> {
114    spec: &'a Spec,
115    /// `x-rust-name` overrides keyed by original schema name.
116    renames: &'a std::collections::HashMap<String, String>,
117    extra: Vec<Item>,
118    /// Current inline-nesting depth, bounded by [`MAX_SCHEMA_DEPTH`].
119    depth: usize,
120}
121
122impl Mapper<'_> {
123    /// The name a schema `$ref` at `site` points to.
124    ///
125    /// Two faults end generation here. The ref can have a form no schema site
126    /// accepts, which [`schema_ref_reason`] describes. The ref can also name a
127    /// schema the document does not declare. Without the second check the name
128    /// reaches the output, and the generated file does not compile.
129    fn schema_ref_target(&self, reference: &str, site: &str) -> Result<String> {
130        let target = ref_target_name(reference).ok_or_else(|| {
131            return Error::UnsupportedRef {
132                reference: reference.to_owned(),
133                reason: schema_ref_reason(reference, site),
134            };
135        })?;
136        if !self.spec.schemas().contains_key(target) {
137            return Err(Error::UnresolvedRef(reference.to_owned()));
138        }
139        return Ok(target.to_owned());
140    }
141
142    /// The identifier for a top-level type, honouring an `x-rust-name` override.
143    fn type_name_ident(&self, name: &str) -> crate::naming::RustIdent {
144        let effective = self.renames.get(name).map(String::as_str).unwrap_or(name);
145        return to_ident(effective, Case::Pascal);
146    }
147
148    /// Lower a top-level named schema into a single item.
149    fn named_to_item(&mut self, name: &str, schema: &Schema) -> Result<Item> {
150        let data = &schema.schema_data;
151
152        if let Some(verbatim) = extension_str(data, X_RUST_TYPE, name)? {
153            return Ok(Item::Alias(Alias {
154                name: self.type_name_ident(name),
155                doc: doc_of(data),
156                deprecated: deprecation_of(data, name)?,
157                ty: verbatim_type(data, verbatim, name)?,
158            }));
159        }
160
161        let item = match &schema.schema_kind {
162            SchemaKind::Type(Type::String(st)) if !st.enumeration.is_empty() => {
163                Item::Enum(self.string_enum(name, &st.enumeration, data)?)
164            }
165            SchemaKind::Type(Type::Integer(it)) if !it.enumeration.is_empty() => {
166                let repr = integer_type(it);
167                Item::Enum(self.integer_enum(name, &it.enumeration, &repr, data)?)
168            }
169            SchemaKind::Type(Type::Object(obj)) => self.object_to_item(name, obj, data)?,
170            SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => {
171                Item::Enum(self.make_union(name, one_of, data)?)
172            }
173            SchemaKind::AllOf { all_of } => match self.single_ref_all_of(all_of)? {
174                Some(target) => Item::Alias(Alias {
175                    name: self.type_name_ident(name),
176                    doc: doc_of(data),
177                    deprecated: deprecation_of(data, name)?,
178                    ty: RustType::Named(target),
179                }),
180                None => Item::Struct(self.merge_all_of(name, all_of, data)?),
181            },
182            SchemaKind::Type(_) => {
183                let ty = self.type_from_schema(name, schema)?;
184                Item::Alias(Alias {
185                    name: self.type_name_ident(name),
186                    doc: doc_of(data),
187                    deprecated: deprecation_of(data, name)?,
188                    ty,
189                })
190            }
191            SchemaKind::Any(_) => Item::Alias(Alias {
192                name: self.type_name_ident(name),
193                doc: doc_of(data),
194                deprecated: deprecation_of(data, name)?,
195                ty: RustType::Value,
196            }),
197            SchemaKind::Not { .. } => {
198                return Err(Error::UnsupportedSchema {
199                    path: name.to_owned(),
200                    reason: "`not` schemas are not supported".to_owned(),
201                });
202            }
203        };
204        return Ok(item);
205    }
206
207    /// Lower an object schema: a struct when it has properties, otherwise a map
208    /// alias.
209    fn object_to_item(&mut self, name: &str, obj: &ObjectType, data: &SchemaData) -> Result<Item> {
210        if obj.properties.is_empty() {
211            let element = self.additional_properties_type(name, obj)?;
212            return Ok(Item::Alias(Alias {
213                name: self.type_name_ident(name),
214                doc: doc_of(data),
215                deprecated: deprecation_of(data, name)?,
216                ty: RustType::Map(Box::new(element)),
217            }));
218        }
219        let strukt = self.object_to_struct(name, obj, data)?;
220        return Ok(Item::Struct(strukt));
221    }
222
223    /// Build a struct from an object schema's properties.
224    fn object_to_struct(&mut self, name: &str, obj: &ObjectType, data: &SchemaData) -> Result<Struct> {
225        let mut ordered = Vec::with_capacity(obj.properties.len());
226        for (prop_name, prop) in &obj.properties {
227            let required = obj.required.iter().any(|r| {
228                return r == prop_name;
229            });
230            let order = prop_order(prop, &format!("{name}.{prop_name}"))?;
231            let field = self.field_from_prop(name, prop_name, prop, required)?;
232            ordered.push((order, field));
233        }
234        let fields = sort_by_order(ordered);
235
236        let additional_properties = match &obj.additional_properties {
237            Some(AdditionalProperties::Schema(schema)) => {
238                let ty = self.type_from_ref_schema(name, schema.as_ref())?;
239                Some(ty)
240            }
241            Some(AdditionalProperties::Any(true)) => Some(RustType::Value),
242            Some(AdditionalProperties::Any(false)) | None => None,
243        };
244        // Only an explicit `false` denies unknown keys. An absent key permits
245        // them, which is serde's behaviour with no attribute.
246        let deny_unknown_fields = matches!(&obj.additional_properties, Some(AdditionalProperties::Any(false)));
247
248        return Ok(Struct {
249            name: self.type_name_ident(name),
250            doc: doc_of(data),
251            deprecated: deprecation_of(data, name)?,
252            fields,
253            additional_properties,
254            deny_unknown_fields,
255        });
256    }
257
258    /// Build a single struct field from a property schema.
259    fn field_from_prop(
260        &mut self,
261        parent: &str,
262        wire: &str,
263        prop: &ReferenceOr<Box<Schema>>,
264        required: bool,
265    ) -> Result<Field> {
266        let hint = format!("{parent}_{wire}");
267        let at = format!("{parent}.{wire}");
268        let mut ty = self.type_from_schema_ref(&hint, prop)?;
269
270        let data = match prop {
271            ReferenceOr::Item(schema) => Some(&schema.schema_data),
272            ReferenceOr::Reference { .. } => None,
273        };
274
275        // A required property is always present, so its `default` never
276        // applies. Use it, and a payload that omits a required property becomes
277        // valid.
278        let declared = data
279            .filter(|_| return !required)
280            .and_then(|data| return data.default.as_ref());
281
282        let nullable = data.map(|data| return data.nullable).unwrap_or(false);
283        // With a default, an absent property ends up the same as a present one,
284        // so `Option` would only ever hold `Some`. `nullable` is the exception,
285        // because there `null` is a value the property carries.
286        if (!required && declared.is_none()) || nullable {
287            ty = ty.optional();
288        }
289
290        let default = match declared {
291            Some(json) => {
292                let variants_of = |name: &str| {
293                    let ident = self.type_name_ident(name);
294                    return self.extra.iter().find_map(|item| {
295                        return match item {
296                            Item::Enum(enom) if enom.name == ident => match &enom.kind {
297                                EnumKind::Strings(variants) => Some(variants.clone()),
298                                EnumKind::Union(_) | EnumKind::Integers { .. } => None,
299                            },
300                            _ => None,
301                        };
302                    });
303                };
304                Some(lower_default(json, &ty, &variants_of, parent, wire)?)
305            }
306            None => None,
307        };
308
309        let doc = data.and_then(doc_of);
310        let deprecated = match data {
311            Some(data) => deprecation_of(data, &at)?,
312            None => None,
313        };
314        let serde_skip = match data {
315            Some(data) => extension_bool(data, X_RUST_SERDE_SKIP, &at)?.unwrap_or(false),
316            None => false,
317        };
318        let omit_empty = match data {
319            Some(data) => extension_bool(data, X_OMITEMPTY, &at)?,
320            None => None,
321        };
322
323        let rust_name = match data {
324            Some(data) => extension_str(data, X_RUST_NAME, &at)?,
325            None => None,
326        };
327        let ident = match rust_name {
328            Some(custom) => to_ident(custom, Case::Snake),
329            None => to_ident(wire, Case::Snake),
330        };
331        let rename = crate::naming::rename_for(wire, &ident);
332        let constraints = match prop {
333            ReferenceOr::Item(schema) => crate::lower::constraints::constraints_of(schema),
334            // The alias a `$ref` makes carries no serde attribute, so the field
335            // takes the checks the target declares.
336            ReferenceOr::Reference { reference } => self
337                .spec
338                .resolve(reference)
339                .ok()
340                .and_then(crate::lower::constraints::constraints_through_ref),
341        };
342        let field = Field {
343            name: ident,
344            rename,
345            doc,
346            deprecated,
347            ty,
348            required,
349            omit_empty,
350            serde_skip,
351            default,
352            constraints,
353        };
354        crate::lower::constraints::check_constraints(&field)?;
355        return Ok(field);
356    }
357
358    /// Return the referenced schema name when `members` is a single `$ref`
359    /// member, for collapsing a one-element `allOf` at the top level into a type
360    /// alias. Only `$ref` members qualify: a single inline member is left to the
361    /// struct-merge path so a schema never aliases itself.
362    fn single_ref_all_of(&self, members: &[ReferenceOr<Schema>]) -> Result<Option<String>> {
363        let [ReferenceOr::Reference { reference }] = members else {
364            return Ok(None);
365        };
366        let target = self.schema_ref_target(reference, "an allOf member")?;
367        return Ok(Some(target));
368    }
369
370    /// Collapse a single-member `allOf` to the type of its sole member.
371    ///
372    /// A one-element `allOf` carries no composition — it exists only to attach
373    /// sibling keywords (`nullable`, `description`) to a `$ref`, which is the
374    /// canonical OpenAPI 3.0 way to annotate or make a reference nullable. In
375    /// that case the wrapper must resolve to the referenced type itself (reusing
376    /// the shared named schema, and working for enum/union targets too) rather
377    /// than synthesizing a duplicate struct. Multi-member `allOf` is genuine
378    /// composition and returns `None` so the caller merges it as before.
379    fn collapse_single_all_of(&mut self, hint: &str, members: &[ReferenceOr<Schema>]) -> Result<Option<RustType>> {
380        let [only] = members else {
381            return Ok(None);
382        };
383        let ty = match only {
384            ReferenceOr::Reference { reference } => {
385                let target = self.schema_ref_target(reference, "an allOf member")?;
386                RustType::Named(target)
387            }
388            ReferenceOr::Item(schema) => self.type_from_schema(hint, schema)?,
389        };
390        return Ok(Some(ty));
391    }
392
393    /// Merge an `allOf` into a single flat struct, resolving `$ref` members to
394    /// pull in their properties (matching oapi-codegen's behaviour).
395    fn merge_all_of(&mut self, name: &str, members: &[ReferenceOr<Schema>], data: &SchemaData) -> Result<Struct> {
396        let mut merged = MergedObject::default();
397        self.absorb_members(name, members, &mut merged)?;
398
399        let mut ordered = Vec::with_capacity(merged.properties.len());
400        for (wire, prop) in &merged.properties {
401            let required = merged.required.iter().any(|r| {
402                return r == wire;
403            });
404            let order = prop_order(prop, &format!("{name}.{wire}"))?;
405            let field = self.field_from_prop(name, wire, prop, required)?;
406            ordered.push((order, field));
407        }
408        let fields = sort_by_order(ordered);
409
410        return Ok(Struct {
411            name: self.type_name_ident(name),
412            doc: doc_of(data),
413            deprecated: deprecation_of(data, name)?,
414            fields,
415            additional_properties: None,
416            // A merge does not read `additionalProperties` from any member. In
417            // JSON Schema each `allOf` member validates the whole object, so a
418            // member with `additionalProperties: false` rejects every property
419            // that a sibling member declares. A merge that honoured it would
420            // deny the fields it just merged in. The merge drops the key, as it
421            // already drops a member's `additionalProperties` schema.
422            deny_unknown_fields: false,
423        });
424    }
425
426    /// Recursively fold `allOf` members (objects, refs to objects, or nested
427    /// `allOf`) into a single merged object. Shares the [`MAX_SCHEMA_DEPTH`]
428    /// counter with [`Self::type_from_schema`] so nested `allOf` cannot exhaust
429    /// the stack independently of inline-type nesting.
430    fn absorb_members(&mut self, name: &str, members: &[ReferenceOr<Schema>], merged: &mut MergedObject) -> Result<()> {
431        if self.depth >= MAX_SCHEMA_DEPTH {
432            return Err(Error::SchemaDepthExceeded {
433                path: name.to_owned(),
434                limit: MAX_SCHEMA_DEPTH,
435            });
436        }
437        self.depth += 1;
438        let result = self.absorb_members_inner(name, members, merged);
439        self.depth -= 1;
440        return result;
441    }
442
443    fn absorb_members_inner(
444        &mut self,
445        name: &str,
446        members: &[ReferenceOr<Schema>],
447        merged: &mut MergedObject,
448    ) -> Result<()> {
449        for member in members {
450            let schema = match member {
451                ReferenceOr::Item(schema) => schema,
452                ReferenceOr::Reference { reference } => self.spec.resolve(reference)?,
453            };
454            match &schema.schema_kind {
455                SchemaKind::Type(Type::Object(obj)) => merged.absorb(obj),
456                SchemaKind::AllOf { all_of } => self.absorb_members(name, all_of, merged)?,
457                SchemaKind::Type(_)
458                | SchemaKind::OneOf { .. }
459                | SchemaKind::AnyOf { .. }
460                | SchemaKind::Any(_)
461                | SchemaKind::Not { .. } => {
462                    return Err(Error::UnsupportedSchema {
463                        path: name.to_owned(),
464                        reason: "allOf members must be objects or refs to objects".to_owned(),
465                    });
466                }
467            }
468        }
469        return Ok(());
470    }
471    fn make_union(&mut self, name: &str, members: &[ReferenceOr<Schema>], data: &SchemaData) -> Result<Enum> {
472        let variants = match &data.discriminator {
473            Some(disc) if !disc.mapping.is_empty() => self.union_variants_from_mapping(disc)?,
474            Some(_) | None => self.union_variants_from_members(name, members)?,
475        };
476        check_variant_types(name, &variants)?;
477        return Ok(Enum {
478            name: self.type_name_ident(name),
479            doc: doc_of(data),
480            deprecated: deprecation_of(data, name)?,
481            kind: EnumKind::Union(variants),
482        });
483    }
484
485    /// Variant list derived from a discriminator mapping (value -> $ref).
486    fn union_variants_from_mapping(&self, disc: &Discriminator) -> Result<Vec<UnionVariant>> {
487        let mut variants = Vec::with_capacity(disc.mapping.len());
488        let mut seen = std::collections::HashSet::new();
489        for (value, reference) in &disc.mapping {
490            let target = self.schema_ref_target(reference, "a discriminator mapping")?;
491            variants.push(UnionVariant {
492                name: crate::naming::deconflict_ident(to_ident(value, Case::Pascal), &mut seen),
493                ty: RustType::Named(target),
494            });
495        }
496        return Ok(variants);
497    }
498
499    /// Variant list derived from the `oneOf`/`anyOf` member schemas directly.
500    fn union_variants_from_members(
501        &mut self,
502        name: &str,
503        members: &[ReferenceOr<Schema>],
504    ) -> Result<Vec<UnionVariant>> {
505        let mut variants = Vec::with_capacity(members.len());
506        let mut seen = std::collections::HashSet::new();
507        let mut diagnostics = crate::lower::validate::Diagnostics::new();
508        for (index, member) in members.iter().enumerate() {
509            let variant = match member {
510                ReferenceOr::Reference { reference } => {
511                    let target = self.schema_ref_target(reference, "a union member")?;
512                    // Name the variant from the *resolved* type name, so an
513                    // `x-rust-name` override or a configured collision suffix
514                    // reaches the variant too. The raw target name would give a
515                    // variant that contradicts its own payload type.
516                    UnionVariant {
517                        name: crate::naming::deconflict_ident(self.type_name_ident(&target), &mut seen),
518                        ty: RustType::Named(target),
519                    }
520                }
521                ReferenceOr::Item(schema) => {
522                    let Some(seed) = inline_variant_seed(schema, &format!("{name}, member {index}"))? else {
523                        diagnostics.push(Error::UnsupportedSchema {
524                            path: name.to_owned(),
525                            reason: format!("member {index} of the union gives the variant no name"),
526                        });
527                        continue;
528                    };
529                    // The seed names the variant. A type the member hoists goes
530                    // to the crate root, where the seed alone says nothing about
531                    // where it came from and can meet the same name from another
532                    // union. The union name goes in front of it, as it does for
533                    // an inline property. The variant keeps the short name,
534                    // because the enum already says which union it belongs to.
535                    let ty = self.type_from_schema(&format!("{name}_{seed}"), schema)?;
536                    UnionVariant {
537                        name: crate::naming::deconflict_ident(to_ident(&seed, Case::Pascal), &mut seen),
538                        ty,
539                    }
540                }
541            };
542            variants.push(variant);
543        }
544        diagnostics.into_result()?;
545        return Ok(variants);
546    }
547
548    /// Build a string enum from an OpenAPI string `enum`.
549    ///
550    /// `x-enum-varnames` / `x-enumNames` override variant identifiers positionally
551    /// (in declaration order). The wire value is preserved via `#[serde(rename)]`.
552    ///
553    /// A repeated value is an error. The second variant would take the same
554    /// `rename`, which leaves it unreachable and compiles only with a warning.
555    fn string_enum(&self, name: &str, values: &[Option<String>], data: &SchemaData) -> Result<Enum> {
556        let varnames = match extension_str_array(data, X_ENUM_VARNAMES, name)? {
557            Some(names) => Some(names),
558            None => extension_str_array(data, X_ENUM_NAMES, name)?,
559        };
560        let mut diagnostics = crate::lower::validate::Diagnostics::new();
561        let mut variants = Vec::new();
562        let mut seen = std::collections::HashSet::new();
563        let mut values_seen = std::collections::HashSet::new();
564        for (index, value) in values.iter().flatten().enumerate() {
565            if !values_seen.insert(value.as_str()) {
566                diagnostics.push(Error::UnsupportedSchema {
567                    path: name.to_owned(),
568                    reason: format!("the `enum` gives `{value}` more than once"),
569                });
570                continue;
571            }
572            let base = match varnames.as_ref().and_then(|names| return names.get(index)) {
573                Some(custom) => to_ident(custom, Case::Pascal),
574                None => to_ident(value, Case::Pascal),
575            };
576            let ident = crate::naming::deconflict_ident(base, &mut seen);
577            let rename = crate::naming::rename_for(value, &ident);
578            variants.push(StringVariant {
579                name: ident,
580                rename,
581                doc: None,
582            });
583        }
584        diagnostics.into_result()?;
585        return Ok(Enum {
586            name: self.type_name_ident(name),
587            doc: doc_of(data),
588            deprecated: deprecation_of(data, name)?,
589            kind: EnumKind::Strings(variants),
590        });
591    }
592
593    /// Lower an integer schema that carries an `enum` into a C-like Rust enum.
594    ///
595    /// A variant takes its name from `x-enum-varnames` when the document gives
596    /// one. Otherwise the name comes from the value: `1` gives `Value1`, and
597    /// `-1` gives `ValueMinus1`.
598    ///
599    /// A repeated value is an error, because two variants cannot share one
600    /// discriminant (`E0081`). A value the `format` cannot hold is an error for
601    /// the same reason: the literal does not fit the `repr`.
602    fn integer_enum(&self, name: &str, values: &[Option<i64>], repr: &RustType, data: &SchemaData) -> Result<Enum> {
603        let varnames = match extension_str_array(data, X_ENUM_VARNAMES, name)? {
604            Some(names) => Some(names),
605            None => extension_str_array(data, X_ENUM_NAMES, name)?,
606        };
607        let mut diagnostics = crate::lower::validate::Diagnostics::new();
608        let mut variants = Vec::new();
609        let mut seen = std::collections::HashSet::new();
610        let mut values_seen = std::collections::HashSet::new();
611        for (index, value) in values.iter().flatten().enumerate() {
612            if !values_seen.insert(*value) {
613                diagnostics.push(Error::UnsupportedSchema {
614                    path: name.to_owned(),
615                    reason: format!("the `enum` gives `{value}` more than once"),
616                });
617                continue;
618            }
619            if !fits_repr(*value, repr) {
620                diagnostics.push(Error::UnsupportedSchema {
621                    path: name.to_owned(),
622                    reason: format!("the `enum` gives `{value}`, which `{}` cannot hold", repr_name(repr)),
623                });
624                continue;
625            }
626            let base = match varnames.as_ref().and_then(|names| return names.get(index)) {
627                Some(custom) => to_ident(custom, Case::Pascal),
628                None => to_ident(&integer_variant_name(*value), Case::Pascal),
629            };
630            variants.push(IntegerVariant {
631                name: crate::naming::deconflict_ident(base, &mut seen),
632                value: *value,
633                doc: None,
634            });
635        }
636        diagnostics.into_result()?;
637        return Ok(Enum {
638            name: self.type_name_ident(name),
639            doc: doc_of(data),
640            deprecated: deprecation_of(data, name)?,
641            kind: EnumKind::Integers {
642                repr: repr.clone(),
643                variants,
644            },
645        });
646    }
647
648    /// Resolve a property/items/additionalProperties schema reference to a type,
649    /// hoisting inline named types into `self.extra` as needed.
650    fn type_from_schema_ref(&mut self, hint: &str, schema: &ReferenceOr<Box<Schema>>) -> Result<RustType> {
651        match schema {
652            ReferenceOr::Reference { reference } => {
653                let target = self.schema_ref_target(reference, "a property")?;
654                return Ok(RustType::Named(target));
655            }
656            ReferenceOr::Item(schema) => {
657                let ty = self.type_from_schema(hint, schema)?;
658                return Ok(ty);
659            }
660        }
661    }
662
663    /// Resolve an `additionalProperties` schema (an unboxed `ReferenceOr`) to a
664    /// type.
665    fn type_from_ref_schema(&mut self, hint: &str, schema: &ReferenceOr<Schema>) -> Result<RustType> {
666        match schema {
667            ReferenceOr::Reference { reference } => {
668                let target = self.schema_ref_target(reference, "additionalProperties")?;
669                return Ok(RustType::Named(target));
670            }
671            ReferenceOr::Item(schema) => {
672                let ty = self.type_from_schema(hint, schema)?;
673                return Ok(ty);
674            }
675        }
676    }
677
678    /// Map an inline schema to a Rust type, hoisting composite inline schemas
679    /// into named items. Bounds inline nesting via [`MAX_SCHEMA_DEPTH`] so a
680    /// pathological spec errors cleanly instead of exhausting the stack.
681    fn type_from_schema(&mut self, hint: &str, schema: &Schema) -> Result<RustType> {
682        if self.depth >= MAX_SCHEMA_DEPTH {
683            return Err(Error::SchemaDepthExceeded {
684                path: hint.to_owned(),
685                limit: MAX_SCHEMA_DEPTH,
686            });
687        }
688        self.depth += 1;
689        let result = self.type_from_schema_inner(hint, schema);
690        self.depth -= 1;
691        return result;
692    }
693
694    fn type_from_schema_inner(&mut self, hint: &str, schema: &Schema) -> Result<RustType> {
695        let data = &schema.schema_data;
696        if let Some(verbatim) = extension_str(data, X_RUST_TYPE, hint)? {
697            return verbatim_type(data, verbatim, hint);
698        }
699
700        let ty = match &schema.schema_kind {
701            SchemaKind::Type(Type::String(st)) if !st.enumeration.is_empty() => {
702                let enom = self.string_enum(hint, &st.enumeration, data)?;
703                self.extra.push(Item::Enum(enom));
704                RustType::Named(hint.to_owned())
705            }
706            SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
707            SchemaKind::Type(Type::Integer(it)) if !it.enumeration.is_empty() => {
708                let repr = integer_type(it);
709                let enom = self.integer_enum(hint, &it.enumeration, &repr, data)?;
710                self.extra.push(Item::Enum(enom));
711                RustType::Named(hint.to_owned())
712            }
713            SchemaKind::Type(Type::Integer(it)) => integer_type(it),
714            SchemaKind::Type(Type::Number(_)) => RustType::F64,
715            SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
716            SchemaKind::Type(Type::Array(at)) => {
717                let element = match &at.items {
718                    Some(items) => {
719                        let item_hint = format!("{hint}_item");
720                        self.type_from_schema_ref(&item_hint, items)?
721                    }
722                    None => RustType::Value,
723                };
724                RustType::Vec(Box::new(element))
725            }
726            SchemaKind::Type(Type::Object(obj)) => self.inline_object_type(hint, obj, data)?,
727            SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => {
728                let enom = self.make_union(hint, one_of, data)?;
729                self.extra.push(Item::Enum(enom));
730                RustType::Named(hint.to_owned())
731            }
732            SchemaKind::AllOf { all_of } => {
733                if let Some(ty) = self.collapse_single_all_of(hint, all_of)? {
734                    ty
735                } else {
736                    let strukt = self.merge_all_of(hint, all_of, data)?;
737                    self.extra.push(Item::Struct(strukt));
738                    RustType::Named(hint.to_owned())
739                }
740            }
741            SchemaKind::Any(_) => RustType::Value,
742            SchemaKind::Not { .. } => {
743                return Err(Error::UnsupportedSchema {
744                    path: hint.to_owned(),
745                    reason: "`not` schemas are not supported".to_owned(),
746                });
747            }
748        };
749        return Ok(ty);
750    }
751
752    /// Map an inline object: hoist a struct when it has properties, otherwise a
753    /// map of its additionalProperties element type.
754    fn inline_object_type(&mut self, hint: &str, obj: &ObjectType, data: &SchemaData) -> Result<RustType> {
755        if obj.properties.is_empty() {
756            let element = self.additional_properties_type(hint, obj)?;
757            return Ok(RustType::Map(Box::new(element)));
758        }
759        let strukt = self.object_to_struct(hint, obj, data)?;
760        self.extra.push(Item::Struct(strukt));
761        return Ok(RustType::Named(hint.to_owned()));
762    }
763
764    /// Element type for an object used purely as a map (`additionalProperties`).
765    fn additional_properties_type(&mut self, hint: &str, obj: &ObjectType) -> Result<RustType> {
766        let element = match &obj.additional_properties {
767            Some(AdditionalProperties::Schema(schema)) => self.type_from_ref_schema(hint, schema.as_ref())?,
768            Some(AdditionalProperties::Any(_)) | None => RustType::Value,
769        };
770        return Ok(element);
771    }
772}
773
774/// Accumulates merged properties of an `allOf`, preserving first-seen order.
775#[derive(Default)]
776struct MergedObject {
777    properties: indexmap::IndexMap<String, ReferenceOr<Box<Schema>>>,
778    required: Vec<String>,
779}
780
781impl MergedObject {
782    /// Fold one object schema's properties and required list into the merge.
783    fn absorb(&mut self, obj: &ObjectType) {
784        for (name, prop) in &obj.properties {
785            self.properties.insert(name.clone(), prop.clone());
786        }
787        for req in &obj.required {
788            if !self.required.contains(req) {
789                self.required.push(req.clone());
790            }
791        }
792    }
793}
794
795/// Map a string `format` to a Rust type.
796pub(crate) fn string_format_type(format: &VariantOrUnknownOrEmpty<StringFormat>) -> RustType {
797    let ty = match format {
798        VariantOrUnknownOrEmpty::Item(StringFormat::Date) => RustType::Date,
799        VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => RustType::DateTime,
800        VariantOrUnknownOrEmpty::Item(StringFormat::Byte) => RustType::Bytes,
801        VariantOrUnknownOrEmpty::Item(StringFormat::Binary) => RustType::Bytes,
802        VariantOrUnknownOrEmpty::Item(StringFormat::Password) => RustType::String,
803        VariantOrUnknownOrEmpty::Unknown(name) if name == "uuid" => RustType::Uuid,
804        VariantOrUnknownOrEmpty::Unknown(_) => RustType::String,
805        VariantOrUnknownOrEmpty::Empty => RustType::String,
806    };
807    return ty;
808}
809
810/// Whether an integer enum value fits the `repr` its `format` and `minimum` choose.
811///
812/// An unsigned `repr` holds no negative value, so a `minimum` of zero with a
813/// negative `enum` value is a document that disagrees with itself.
814fn fits_repr(value: i64, repr: &RustType) -> bool {
815    return match *repr {
816        RustType::I32 => i32::try_from(value).is_ok(),
817        RustType::U32 => u32::try_from(value).is_ok(),
818        RustType::U64 => u64::try_from(value).is_ok(),
819        _ => true,
820    };
821}
822
823/// The Rust name of an integer enum `repr`, for a diagnostic.
824fn repr_name(repr: &RustType) -> &'static str {
825    return match *repr {
826        RustType::I32 => "i32",
827        RustType::U32 => "u32",
828        RustType::U64 => "u64",
829        _ => "i64",
830    };
831}
832
833/// The default name for an integer enum variant, from its value.
834fn integer_variant_name(value: i64) -> String {
835    if value < 0 {
836        return format!("value_minus_{}", value.unsigned_abs());
837    }
838    return format!("value_{value}");
839}
840
841/// The name an inline union member gives its variant, if it gives one at all.
842///
843/// `x-rust-name` names any member. A member that holds one `enum` value is named
844/// by that value. Otherwise only a member that hoists no type of its own can be
845/// named, and the type it holds gives that name.
846///
847/// A member that hoists — an object, a list, a map, an `enum` of several values —
848/// needs a name for the hoisted type as well as for the variant. The position
849/// would give one, but a position carries no meaning and moves when the document
850/// changes, so the author gives the name instead.
851fn inline_variant_seed(schema: &Schema, at: &str) -> Result<Option<String>> {
852    if let Some(custom) = extension_str(&schema.schema_data, X_RUST_NAME, at)? {
853        return Ok(Some(custom.to_owned()));
854    }
855    if let Some(value) = single_enum_value(schema) {
856        return Ok(Some(value));
857    }
858    let Some(ty) = non_hoisting_type(schema) else {
859        return Ok(None);
860    };
861    return Ok(type_variant_name(&ty).map(str::to_owned));
862}
863
864/// The one value a member holds, when the member is a string `enum` of one value.
865///
866/// Such a member stands for a constant. The value names the variant, because a
867/// constant says what it is and the position adds nothing. A document that lists
868/// a Rust enum as a `oneOf` writes every unit variant this way, so this shape is
869/// common.
870fn single_enum_value(schema: &Schema) -> Option<String> {
871    let SchemaKind::Type(Type::String(st)) = &schema.schema_kind else {
872        return None;
873    };
874    let [Some(value)] = st.enumeration.as_slice() else {
875        return None;
876    };
877    return Some(value.clone());
878}
879
880/// The type an inline union member holds when it hoists nothing.
881///
882/// `None` means the member hoists a type of its own, which then needs a name.
883fn non_hoisting_type(schema: &Schema) -> Option<RustType> {
884    return match &schema.schema_kind {
885        SchemaKind::Type(Type::String(st)) if st.enumeration.is_empty() => Some(string_format_type(&st.format)),
886        SchemaKind::Type(Type::Integer(it)) if it.enumeration.is_empty() => Some(integer_type(it)),
887        SchemaKind::Type(Type::Number(_)) => Some(RustType::F64),
888        SchemaKind::Type(Type::Boolean(_)) => Some(RustType::Bool),
889        _ => None,
890    };
891}
892
893/// The variant name a type gives, for a union member that hoists nothing.
894///
895/// A union cannot hold one type twice, so these names stay unique.
896fn type_variant_name(ty: &RustType) -> Option<&'static str> {
897    return match ty {
898        RustType::Bool => Some("Bool"),
899        RustType::I32 => Some("I32"),
900        RustType::I64 => Some("I64"),
901        RustType::U32 => Some("U32"),
902        RustType::U64 => Some("U64"),
903        RustType::F64 => Some("F64"),
904        RustType::String => Some("String"),
905        RustType::Date => Some("Date"),
906        RustType::DateTime => Some("DateTime"),
907        RustType::Uuid => Some("Uuid"),
908        RustType::Bytes => Some("Bytes"),
909        _ => None,
910    };
911}
912
913/// Reject a union that holds one type more than once.
914///
915/// The emitted enum is `#[serde(untagged)]`. Serde reads the variants in order
916/// and takes the first that fits, so a repeated type makes the later variant
917/// unreachable. A value built with that variant comes back as the earlier one,
918/// which changes the value and reports nothing.
919fn check_variant_types(name: &str, variants: &[UnionVariant]) -> Result<()> {
920    let mut diagnostics = crate::lower::validate::Diagnostics::new();
921    for (index, variant) in variants.iter().enumerate() {
922        let Some(earlier) = variants.iter().take(index).find(|other| return other.ty == variant.ty) else {
923            continue;
924        };
925        diagnostics.push(Error::UnsupportedSchema {
926            path: name.to_owned(),
927            reason: format!(
928                "the union holds `{}` twice, as `{}` and as `{}`",
929                variant.ty.label(),
930                earlier.name.logical(),
931                variant.name.logical()
932            ),
933        });
934    }
935    return diagnostics.into_result();
936}
937
938/// Map an integer schema to a Rust type.
939///
940/// `format` gives the width. A lowest value of zero or more gives the sign: the
941/// document states the value is never negative, so an unsigned type says the
942/// same thing in the type system, and the reader of the field needs no check to
943/// know it. OpenAPI has no unsigned format, so the bound is the only place a
944/// document can put this. `exclusiveMinimum` counts too, because a whole number
945/// above `-1` is zero or more.
946///
947/// The bound stays a bound. It still becomes a check where the value comes in,
948/// unless the type already refuses every value it would reject.
949pub(crate) fn integer_type(it: &IntegerType) -> RustType {
950    let unsigned = matches!(crate::lower::constraints::inclusive_minimum(it), Some(minimum) if minimum >= 0);
951    let ty = match (&it.format, unsigned) {
952        (VariantOrUnknownOrEmpty::Item(IntegerFormat::Int32), false) => RustType::I32,
953        (VariantOrUnknownOrEmpty::Item(IntegerFormat::Int32), true) => RustType::U32,
954        (VariantOrUnknownOrEmpty::Item(IntegerFormat::Int64), false)
955        | (VariantOrUnknownOrEmpty::Unknown(_) | VariantOrUnknownOrEmpty::Empty, false) => RustType::I64,
956        (VariantOrUnknownOrEmpty::Item(IntegerFormat::Int64), true)
957        | (VariantOrUnknownOrEmpty::Unknown(_) | VariantOrUnknownOrEmpty::Empty, true) => RustType::U64,
958    };
959    return ty;
960}
961
962/// Extract a string-valued extension (for example `x-rust-type`) from schema data.
963fn extension_str<'a>(data: &'a SchemaData, key: &str, at: &str) -> Result<Option<&'a str>> {
964    return crate::lower::extension::str_value(&data.extensions, key, at);
965}
966
967/// Extract a boolean-valued extension (for example `x-omitempty`) from schema data.
968fn extension_bool(data: &SchemaData, key: &str, at: &str) -> Result<Option<bool>> {
969    return crate::lower::extension::bool_value(&data.extensions, key, at);
970}
971
972/// Extract an integer-valued extension (for example `x-order`) from schema data.
973fn extension_i64(data: &SchemaData, key: &str, at: &str) -> Result<Option<i64>> {
974    return crate::lower::extension::i64_value(&data.extensions, key, at);
975}
976
977/// The `x-order` value of a property, if it carries one (only inline schemas can).
978fn prop_order(prop: &ReferenceOr<Box<Schema>>, at: &str) -> Result<Option<i64>> {
979    return match prop {
980        ReferenceOr::Item(schema) => extension_i64(&schema.schema_data, X_ORDER, at),
981        ReferenceOr::Reference { .. } => Ok(None),
982    };
983}
984
985/// Order fields by their `x-order` (ascending), keeping fields without one in
986/// their original declaration order after the ordered ones (a stable sort with
987/// unordered fields treated as coming last).
988fn sort_by_order(mut fields: Vec<(Option<i64>, Field)>) -> Vec<Field> {
989    fields.sort_by_key(|(order, _)| {
990        return order.unwrap_or(i64::MAX);
991    });
992    return fields.into_iter().map(|(_, field)| return field).collect();
993}
994
995/// Extract a string-array extension (for example `x-enum-varnames`).
996fn extension_str_array<'a>(data: &'a SchemaData, key: &str, at: &str) -> Result<Option<Vec<&'a str>>> {
997    return crate::lower::extension::str_list_value(&data.extensions, key, at);
998}
999
1000/// The trait names `x-rust-derive` accepts, in the order the derive list emits
1001/// them. Only these three, because they are the only traits the generator derives
1002/// on a model without being asked. A serde trait is decided by the direction the
1003/// API uses the model in, which [`crate::emit::usage`] computes and no
1004/// specification overrides.
1005const FOREIGN_DERIVE_NAMES: [&str; 3] = ["Debug", "Clone", "PartialEq"];
1006
1007/// Read `x-rust-derive` into a [`ForeignDerives`].
1008///
1009/// The value lists the traits the target **does** implement, so an absent key
1010/// means all three and an empty list means none. Listing what is present rather
1011/// than what is missing keeps the specification readable: a reader sees the
1012/// target's capability and not a double negative.
1013///
1014/// An unknown trait name is an error and not an ignored key. The whole point of
1015/// the extension is to stop a bound the author cannot satisfy, so a misspelled
1016/// `Parialeq` that silently claims nothing would give exactly the compile error
1017/// the author wrote the key to avoid, with nothing pointing at the typo.
1018fn foreign_derives_of(data: &SchemaData, path: &str) -> Result<ForeignDerives> {
1019    let Some(value) = data.extensions.get(X_RUST_DERIVE) else {
1020        return Ok(ForeignDerives::default());
1021    };
1022    let Some(array) = value.as_array() else {
1023        return Err(Error::UnsupportedSchema {
1024            path: path.to_owned(),
1025            reason: format!("`{X_RUST_DERIVE}` must be a list of trait names, for example `[Debug, Clone]`"),
1026        });
1027    };
1028
1029    let mut derives = ForeignDerives {
1030        debug: false,
1031        clone: false,
1032        partial_eq: false,
1033    };
1034    for entry in array {
1035        let Some(name) = entry.as_str() else {
1036            return Err(Error::UnsupportedSchema {
1037                path: path.to_owned(),
1038                reason: format!("every `{X_RUST_DERIVE}` entry must be a trait name written as a string"),
1039            });
1040        };
1041        match name {
1042            "Debug" => derives.debug = true,
1043            "Clone" => derives.clone = true,
1044            "PartialEq" => derives.partial_eq = true,
1045            other => {
1046                let known = FOREIGN_DERIVE_NAMES.join(", ");
1047                return Err(Error::UnsupportedSchema {
1048                    path: path.to_owned(),
1049                    reason: format!("`{X_RUST_DERIVE}` does not accept `{other}`. It accepts only {known}"),
1050                });
1051            }
1052        }
1053    }
1054    return Ok(derives);
1055}
1056
1057/// Lower an `x-rust-type` target into a [`RustType::Verbatim`], reading its
1058/// `x-rust-derive` alongside. Both keys sit on one schema, so they are read
1059/// together and neither site has to remember the other exists.
1060fn verbatim_type(data: &SchemaData, verbatim: &str, path: &str) -> Result<RustType> {
1061    return Ok(RustType::Verbatim {
1062        text: verbatim.to_owned(),
1063        derives: foreign_derives_of(data, path)?,
1064    });
1065}
1066
1067/// Derive a `#[deprecated]` annotation from `deprecated: true` and an optional
1068/// `x-deprecated-reason` note. Returns `None` unless the schema is deprecated,
1069/// so a lone `x-deprecated-reason` is a no-op (matching `oapi-codegen`).
1070fn deprecation_of(data: &SchemaData, at: &str) -> Result<Option<Deprecation>> {
1071    if !data.deprecated {
1072        return Ok(None);
1073    }
1074    let note = extension_str(data, X_DEPRECATED_REASON, at)?.map(str::to_owned);
1075    return Ok(Some(Deprecation { note }));
1076}
1077
1078/// Trim and normalise a schema `description` into a doc comment.
1079fn doc_of(data: &SchemaData) -> Option<String> {
1080    let text = data.description.as_ref()?;
1081    let trimmed = text.trim();
1082    if trimmed.is_empty() {
1083        return None;
1084    }
1085    return Some(trimmed.to_owned());
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090    use std::path::PathBuf;
1091
1092    use super::*;
1093
1094    /// Parse an inline OpenAPI document and emit the generated Rust source.
1095    fn emit_yaml(yaml: &str) -> String {
1096        let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
1097        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1098        let module = lower_models(&spec).expect("map schemas");
1099        return crate::emit::emit_module(&module, None).expect("emit module");
1100    }
1101
1102    /// Lower a spec and give back the outcome, so a test can check a rejection.
1103    fn lower_yaml(yaml: &str) -> Result<Module> {
1104        let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
1105        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1106        return lower_models(&spec);
1107    }
1108
1109    /// Lower every schema with no configured suffix. None of the specs below
1110    /// holds a name collision, so the resolved names need no further check.
1111    fn lower_models(spec: &Spec) -> Result<Module> {
1112        let names = crate::lower::rename::type_renames(spec, None)?;
1113        return generate_models(spec, &names);
1114    }
1115
1116    const PREAMBLE: &str = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n";
1117
1118    /// A schema of `depth` nested inline arrays terminating in a string, built
1119    /// programmatically so the depth guard — not the YAML parser's own recursion
1120    /// limit or a parse-time stack overflow — is what the test exercises.
1121    fn nested_array_schema(depth: usize) -> Schema {
1122        let mut kind = SchemaKind::Type(Type::String(Default::default()));
1123        for _ in 0..depth {
1124            let items = ReferenceOr::Item(Box::new(Schema {
1125                schema_data: SchemaData::default(),
1126                schema_kind: kind,
1127            }));
1128            kind = SchemaKind::Type(Type::Array(openapiv3::ArrayType {
1129                items: Some(items),
1130                min_items: None,
1131                max_items: None,
1132                unique_items: false,
1133            }));
1134        }
1135        return Schema {
1136            schema_data: SchemaData::default(),
1137            schema_kind: kind,
1138        };
1139    }
1140
1141    fn spec_with_schema(name: &str, schema: Schema) -> Spec {
1142        let empty_doc = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\n";
1143        let mut doc: openapiv3::OpenAPI = serde_yaml::from_str(empty_doc).expect("parse preamble");
1144        doc.components
1145            .get_or_insert_with(Default::default)
1146            .schemas
1147            .insert(name.to_owned(), ReferenceOr::Item(schema));
1148        return Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1149    }
1150    // The two tests below bracket the guard boundary exactly: the deepest
1151    // schema that lowers is `MAX_SCHEMA_DEPTH - 1` levels, and reaching
1152    // `MAX_SCHEMA_DEPTH` errors. Any off-by-one in the guard breaks one of them.
1153
1154    #[test]
1155    fn schema_at_the_depth_limit_errors_instead_of_overflowing() {
1156        let spec = spec_with_schema("Deep", nested_array_schema(MAX_SCHEMA_DEPTH));
1157        let err = lower_models(&spec).expect_err("reaching the limit should hit the depth guard");
1158        assert!(
1159            matches!(err, Error::SchemaDepthExceeded { limit, .. } if limit == MAX_SCHEMA_DEPTH),
1160            "expected SchemaDepthExceeded, got {err:?}"
1161        );
1162    }
1163
1164    #[test]
1165    fn schema_just_under_the_depth_limit_still_lowers() {
1166        let spec = spec_with_schema("Deep", nested_array_schema(MAX_SCHEMA_DEPTH - 1));
1167        lower_models(&spec).expect("just under the limit should lower cleanly");
1168    }
1169
1170    /// Lower an inline document and return the error it gives.
1171    fn lower_error(yaml: &str) -> Error {
1172        let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
1173        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1174        return lower_models(&spec).expect_err("the spec should not lower");
1175    }
1176
1177    /// A schema whose `x-order` holds a string, which the extension rejects.
1178    fn bad_order_schema(name: &str) -> String {
1179        return format!(
1180            "    {name}:\n      type: object\n      properties:\n        id:\n          type: string\n          x-order: 'first'\n"
1181        );
1182    }
1183
1184    #[test]
1185    fn every_bad_schema_is_reported_in_one_run() {
1186        // Component schemas are independent, so a fault in one says nothing about
1187        // the next. Reporting the first alone costs the author one run for each
1188        // fault.
1189        let alpha = bad_order_schema("Alpha");
1190        let beta = bad_order_schema("Beta");
1191        let gamma = bad_order_schema("Gamma");
1192        let err = lower_error(&format!("{PREAMBLE}{alpha}{beta}{gamma}"));
1193        let Error::Validation { problems } = &err else {
1194            panic!("expected Validation, got: {err:?}");
1195        };
1196        assert_eq!(problems.len(), 3, "every bad schema should be reported");
1197        let message = err.to_string();
1198        for name in ["Alpha", "Beta", "Gamma"] {
1199            assert!(message.contains(name), "message should name `{name}`: {message}");
1200        }
1201    }
1202
1203    #[test]
1204    fn a_good_schema_beside_a_bad_one_does_not_add_a_problem() {
1205        let alpha = bad_order_schema("Alpha");
1206        let err = lower_error(&format!("{PREAMBLE}{alpha}    Beta:\n      type: string\n"));
1207        assert!(
1208            matches!(&err, Error::InvalidExtensionValue { at, .. } if at == "Alpha.id"),
1209            "one problem should stay unwrapped, got: {err:?}",
1210        );
1211    }
1212
1213    #[test]
1214    fn a_union_member_holding_one_enum_value_is_named_by_that_value() {
1215        // The shape a document gives a Rust enum whose variants carry no data.
1216        // Before this rule every member needed an `x-rust-name`.
1217        let out = emit_yaml(&format!(
1218            "{PREAMBLE}    Signal:\n      oneOf:\n      - type: string\n        enum: [red]\n      - type: string\n        enum: [amber]\n"
1219        ));
1220        assert!(out.contains("Red(SignalRed)"), "expected a named variant, got: {out}");
1221        assert!(
1222            out.contains("Amber(SignalAmber)"),
1223            "expected a named variant, got: {out}"
1224        );
1225        // The hoisted type carries the wire value, so the untagged enum writes
1226        // the constant and not `null`.
1227        assert!(
1228            out.contains("rename = \"red\""),
1229            "the hoisted type should keep the wire value, got: {out}"
1230        );
1231    }
1232
1233    #[test]
1234    fn a_hoisted_union_member_type_carries_the_union_name() {
1235        // Two unions can each hold a member named `Unknown`. Without the union
1236        // name in front, both hoist to `Unknown` and generation stops.
1237        let out = emit_yaml(&format!(
1238            "{PREAMBLE}    Left:\n      oneOf:\n      - x-rust-name: Unknown\n        type: object\n        required: [a]\n        properties:\n          a:\n            type: string\n    Right:\n      oneOf:\n      - x-rust-name: Unknown\n        type: object\n        required: [b]\n        properties:\n          b:\n            type: string\n"
1239        ));
1240        assert!(out.contains("struct LeftUnknown"), "expected LeftUnknown, got: {out}");
1241        assert!(out.contains("struct RightUnknown"), "expected RightUnknown, got: {out}");
1242        // The variant keeps the short name; the enum already says which union.
1243        assert!(
1244            out.contains("Unknown(LeftUnknown)"),
1245            "expected a short variant, got: {out}"
1246        );
1247    }
1248
1249    /// Lower a spec whose one schema carries an `x-rust-derive` and return the
1250    /// error, for the shapes the extension rejects.
1251    fn derive_error(value: &str) -> Error {
1252        let yaml = format!(
1253            "{PREAMBLE}    Target:\n      type: string\n      x-rust-type: crate::Foreign\n      x-rust-derive: {value}\n"
1254        );
1255        let doc: openapiv3::OpenAPI = serde_yaml::from_str(&yaml).expect("parse spec");
1256        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1257        return lower_models(&spec).expect_err("the extension should reject this value");
1258    }
1259
1260    #[test]
1261    fn absent_x_rust_derive_claims_every_trait() {
1262        // The default every specification written before the extension existed
1263        // relies on. A model reaching the target keeps all three traits.
1264        let out = emit_yaml(&format!(
1265            "{PREAMBLE}    Holder:\n      type: object\n      required: [value]\n      properties:\n        value:\n          type: string\n          x-rust-type: crate::Foreign\n"
1266        ));
1267        assert!(
1268            out.contains("#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]"),
1269            "absent key should change nothing:\n{out}"
1270        );
1271    }
1272
1273    #[test]
1274    fn empty_x_rust_derive_drops_every_trait() {
1275        let out = emit_yaml(&format!(
1276            "{PREAMBLE}    Holder:\n      type: object\n      required: [value]\n      properties:\n        value:\n          type: string\n          x-rust-type: crate::Foreign\n          x-rust-derive: []\n"
1277        ));
1278        assert!(
1279            out.contains("#[derive(serde::Serialize, serde::Deserialize)]"),
1280            "an empty list claims nothing, so only the serde derives remain:\n{out}"
1281        );
1282    }
1283
1284    #[test]
1285    fn partial_x_rust_derive_keeps_only_the_listed_traits() {
1286        let out = emit_yaml(&format!(
1287            "{PREAMBLE}    Holder:\n      type: object\n      required: [value]\n      properties:\n        value:\n          type: string\n          x-rust-type: crate::Foreign\n          x-rust-derive: [Debug, PartialEq]\n"
1288        ));
1289        assert!(
1290            out.contains("#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]"),
1291            "Clone was not listed, so it is dropped:\n{out}"
1292        );
1293    }
1294
1295    #[test]
1296    fn x_rust_derive_that_is_not_a_list_is_rejected() {
1297        let err = derive_error("Debug");
1298        assert!(
1299            matches!(&err, Error::UnsupportedSchema { reason, .. } if reason.contains("must be a list")),
1300            "expected a list-shape error, got {err:?}"
1301        );
1302    }
1303
1304    #[test]
1305    fn x_rust_derive_entry_that_is_not_a_string_is_rejected() {
1306        let err = derive_error("[7]");
1307        assert!(
1308            matches!(&err, Error::UnsupportedSchema { reason, .. } if reason.contains("written as a string")),
1309            "expected a string-entry error, got {err:?}"
1310        );
1311    }
1312
1313    #[test]
1314    fn misspelled_trait_name_is_an_error_and_not_an_ignored_key() {
1315        // Silently accepting `Parialeq` would claim nothing and produce exactly the
1316        // compile error the author wrote the key to prevent, with nothing pointing
1317        // at the typo.
1318        let err = derive_error("[Parialeq]");
1319        assert!(
1320            matches!(&err, Error::UnsupportedSchema { reason, .. } if reason.contains("Parialeq")),
1321            "expected the unknown name in the error, got {err:?}"
1322        );
1323    }
1324
1325    #[test]
1326    fn maps_string_and_integer_formats() {
1327        let yaml = format!(
1328            "{PREAMBLE}    Thing:\n      type: object\n      required: [day, at, id, blob, big]\n      properties:\n        day:\n          type: string\n          format: date\n        at:\n          type: string\n          format: date-time\n        id:\n          type: string\n          format: uuid\n        blob:\n          type: string\n          format: byte\n        big:\n          type: integer\n          format: int64\n"
1329        );
1330        let out = emit_yaml(&yaml);
1331        assert!(out.contains("pub day: chrono::NaiveDate"), "{out}");
1332        assert!(out.contains("pub at: chrono::DateTime<chrono::Utc>"), "{out}");
1333        assert!(out.contains("pub id: uuid::Uuid"), "{out}");
1334        assert!(out.contains("pub blob: Vec<u8>"), "{out}");
1335        assert!(out.contains("pub big: i64"), "{out}");
1336    }
1337
1338    #[test]
1339    fn an_unsigned_field_keeps_every_check_the_type_does_not_already_make() {
1340        let yaml = format!(
1341            "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: int32\n          minimum: 0\n          maximum: 130\n"
1342        );
1343        let out = emit_yaml(&yaml);
1344        assert!(out.contains("pub count: u32"), "{out}");
1345        // The upper bound still has values to reject, so it stays.
1346        assert!(out.contains("`count` must be 130 or less"), "{out}");
1347        // `u32` refuses a value below zero on its own, so the lower bound
1348        // writes a test no value fails. Rust reads `x < 0` on an unsigned type
1349        // as a warning.
1350        assert!(!out.contains("must be 0 or more"), "{out}");
1351        assert!(!out.contains("*item < 0"), "{out}");
1352    }
1353
1354    #[test]
1355    fn an_exclusive_bound_moves_onto_the_whole_number_beside_it() {
1356        // A whole number above `-1` is zero or more, so the field takes an
1357        // unsigned type and needs no check. Each case gives the keyword, the
1358        // bound, the expected type, and the text the message holds.
1359        let cases: [(&str, &str, &str, Option<&str>); 5] = [
1360            ("minimum: -1\n          exclusiveMinimum: true", "int32", "u32", None),
1361            (
1362                "minimum: 0\n          exclusiveMinimum: true",
1363                "int32",
1364                "u32",
1365                Some("must be 1 or more"),
1366            ),
1367            (
1368                "minimum: -2\n          exclusiveMinimum: true",
1369                "int32",
1370                "i32",
1371                Some("must be -1 or more"),
1372            ),
1373            (
1374                "maximum: 2147483648\n          exclusiveMaximum: true",
1375                "int32",
1376                "i32",
1377                None,
1378            ),
1379            ("minimum: -1\n          exclusiveMinimum: true", "int64", "u64", None),
1380        ];
1381        for (bound, format, ty, message) in cases {
1382            let yaml = format!(
1383                "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: {format}\n          {bound}\n"
1384            );
1385            let out = emit_yaml(&yaml);
1386            assert!(out.contains(&format!("pub count: {ty}")), "{bound}: {out}");
1387            match message {
1388                Some(text) => assert!(out.contains(text), "{bound}: {out}"),
1389                None => assert!(!out.contains("must be"), "{bound}: {out}"),
1390            }
1391        }
1392    }
1393
1394    #[test]
1395    fn a_bound_that_lands_on_the_limit_of_the_type_writes_no_check() {
1396        // `i32` refuses every value above 2147483647 on its own, so the test
1397        // would never fail, and Rust reads it as a useless comparison.
1398        let yaml = format!(
1399            "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: int32\n          minimum: -2147483648\n          maximum: 2147483647\n"
1400        );
1401        let out = emit_yaml(&yaml);
1402        assert!(out.contains("pub count: i32"), "{out}");
1403        assert!(!out.contains("must be"), "{out}");
1404    }
1405
1406    #[test]
1407    fn bounds_that_meet_nowhere_are_refused() {
1408        // A field whose bounds accept no value would refuse every request, so
1409        // the fault belongs at generation. A bound the author writes out of
1410        // range is a different fault, and it keeps the message about width.
1411        // Each case gives the `format`, the bounds, and the text of the fault.
1412        let cases: [(&str, &str, Option<&str>); 13] = [
1413            (
1414                "integer\n          format: int32",
1415                "maximum: -2147483648\n          exclusiveMaximum: true",
1416                Some("nothing lies below `-2147483648`, where `i32` starts"),
1417            ),
1418            (
1419                "integer\n          format: int32",
1420                "maximum: -2147483647\n          exclusiveMaximum: true",
1421                None,
1422            ),
1423            (
1424                "integer",
1425                "minimum: 10\n          maximum: 5",
1426                Some("they allow `10` to `5`"),
1427            ),
1428            ("integer", "minimum: 5\n          maximum: 5", None),
1429            (
1430                "integer",
1431                "maximum: -9223372036854775808\n          exclusiveMaximum: true",
1432                Some("nothing lies below `-9223372036854775808`, where `i64` starts"),
1433            ),
1434            (
1435                "integer\n          format: int32",
1436                "minimum: 4294967295\n          exclusiveMinimum: true",
1437                Some("nothing lies above `4294967295`, where `u32` stops"),
1438            ),
1439            (
1440                "integer\n          format: int32",
1441                "minimum: 2147483647\n          maximum: 2147483647",
1442                None,
1443            ),
1444            // `u64` reaches above where `i64` stops, so this bound is reachable.
1445            (
1446                "integer\n          format: int64",
1447                "minimum: 9223372036854775807\n          exclusiveMinimum: true",
1448                None,
1449            ),
1450            // `u32` stops first, so the same bound reaches nothing.
1451            (
1452                "integer\n          format: int32",
1453                "minimum: 9223372036854775807\n          exclusiveMinimum: true",
1454                Some("nothing lies above `9223372036854775807`, where `u32` stops"),
1455            ),
1456            // A float folds no flag, so both readings run on the bounds as written.
1457            (
1458                "number",
1459                "minimum: 10\n          maximum: 5",
1460                Some("they allow `10` to `5`"),
1461            ),
1462            (
1463                "number",
1464                "minimum: 5\n          maximum: 5\n          exclusiveMinimum: true",
1465                Some("they meet at `5`, which an `exclusive` flag then leaves out"),
1466            ),
1467            (
1468                "number",
1469                "minimum: 0\n          maximum: 1\n          exclusiveMaximum: true",
1470                None,
1471            ),
1472            // Written out of range, not folded there: `i64` holds this bound.
1473            (
1474                "integer\n          format: int32",
1475                "maximum: -5000000000",
1476                Some("the `maximum` value `-5000000000` does not fit `i32`"),
1477            ),
1478        ];
1479        for (kind, bounds, fault) in cases {
1480            let yaml = format!(
1481                "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: {kind}\n          {bounds}\n"
1482            );
1483            let outcome = lower_yaml(&yaml);
1484            match fault {
1485                Some(text) => {
1486                    let error = outcome.expect_err(bounds).to_string();
1487                    assert!(error.contains(text), "{bounds}: {error}");
1488                }
1489                None => assert!(outcome.is_ok(), "{bounds}: got {outcome:?}"),
1490            }
1491        }
1492    }
1493
1494    #[test]
1495    fn a_negative_multiple_of_reports_one_fault_only() {
1496        // An unsigned type holds no negative value, so the width check would
1497        // report the step a second time. The step is not above zero whatever the
1498        // width, so widening the type is the wrong fix and the wrong hint.
1499        let yaml = format!(
1500            "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: int32\n          minimum: 0\n          multipleOf: -1\n"
1501        );
1502        let fault = lower_yaml(&yaml).expect_err("refuse the step").to_string();
1503        assert!(fault.contains("is not above zero"), "{fault}");
1504        assert!(!fault.contains("does not fit"), "{fault}");
1505    }
1506
1507    #[test]
1508    fn an_enum_value_must_fit_the_repr_the_format_and_the_minimum_choose() {
1509        // Each case gives the `format`, the `minimum`, the value, and whether
1510        // the value fits. A `minimum` of zero makes the `repr` unsigned, and an
1511        // unsigned `repr` holds no negative value.
1512        let cases: [(&str, &str, i64, bool); 8] = [
1513            ("int32", "", -1, true),
1514            ("int32", "", 4_294_967_296, false),
1515            ("int32", "\n      minimum: 0", -1, false),
1516            ("int32", "\n      minimum: 0", 5, true),
1517            ("int32", "\n      minimum: 0", 4_294_967_296, false),
1518            ("int64", "\n      minimum: 0", -1, false),
1519            ("int64", "\n      minimum: 0", 4_294_967_296, true),
1520            ("int64", "", -1, true),
1521        ];
1522        for (format, minimum, value, fits) in cases {
1523            let yaml = format!(
1524                "{PREAMBLE}    Offset:\n      type: integer\n      format: {format}{minimum}\n      enum:\n        - {value}\n"
1525            );
1526            let outcome = lower_yaml(&yaml);
1527            assert_eq!(
1528                outcome.is_ok(),
1529                fits,
1530                "format `{format}`, minimum `{minimum}`, value `{value}`: got {outcome:?}"
1531            );
1532        }
1533    }
1534
1535    #[test]
1536    fn a_minimum_of_zero_or_more_gives_an_unsigned_type() {
1537        let yaml = format!(
1538            "{PREAMBLE}    Thing:\n      type: object\n      required: [count, total, plain, signed, above]\n      properties:\n        count:\n          type: integer\n          format: int32\n          minimum: 0\n        total:\n          type: integer\n          format: int64\n          minimum: 0\n        plain:\n          type: integer\n          minimum: 0\n        signed:\n          type: integer\n          format: int32\n          minimum: -1\n        above:\n          type: integer\n          format: int32\n          minimum: 5\n"
1539        );
1540        let out = emit_yaml(&yaml);
1541        assert!(out.contains("pub count: u32"), "{out}");
1542        assert!(out.contains("pub total: u64"), "{out}");
1543        assert!(out.contains("pub plain: u64"), "{out}");
1544        // A negative bound leaves the value able to go below zero.
1545        assert!(out.contains("pub signed: i32"), "{out}");
1546        // Any bound at zero or above says the same thing about the sign.
1547        assert!(out.contains("pub above: u32"), "{out}");
1548    }
1549
1550    #[test]
1551    fn an_integer_without_a_minimum_stays_signed() {
1552        let yaml = format!(
1553            "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: int32\n          maximum: 10\n"
1554        );
1555        let out = emit_yaml(&yaml);
1556        assert!(out.contains("pub count: i32"), "{out}");
1557    }
1558
1559    #[test]
1560    fn object_with_only_additional_properties_becomes_map_alias() {
1561        let yaml =
1562            format!("{PREAMBLE}    Dict:\n      type: object\n      additionalProperties:\n        type: string\n");
1563        let out = emit_yaml(&yaml);
1564        assert!(
1565            out.contains("pub type Dict = std::collections::HashMap<String, String>;"),
1566            "{out}"
1567        );
1568    }
1569
1570    #[test]
1571    fn inline_nested_object_is_hoisted() {
1572        let yaml = format!(
1573            "{PREAMBLE}    Outer:\n      type: object\n      required: [inner]\n      properties:\n        inner:\n          type: object\n          required: [x]\n          properties:\n            x:\n              type: string\n"
1574        );
1575        let out = emit_yaml(&yaml);
1576        assert!(out.contains("pub struct Outer"), "{out}");
1577        assert!(out.contains("pub inner: OuterInner"), "{out}");
1578        assert!(out.contains("pub struct OuterInner"), "{out}");
1579        assert!(out.contains("pub x: String"), "{out}");
1580    }
1581
1582    #[test]
1583    fn x_rust_type_emits_verbatim_type() {
1584        let yaml = format!(
1585            "{PREAMBLE}    Holder:\n      type: object\n      required: [v]\n      properties:\n        v:\n          type: string\n          x-rust-type: my_crate::Custom\n"
1586        );
1587        let out = emit_yaml(&yaml);
1588        assert!(out.contains("pub v: my_crate::Custom"), "{out}");
1589    }
1590
1591    #[test]
1592    fn optional_field_is_wrapped_and_skipped() {
1593        let yaml = format!(
1594            "{PREAMBLE}    Maybe:\n      type: object\n      properties:\n        note:\n          type: string\n"
1595        );
1596        let out = emit_yaml(&yaml);
1597        assert!(out.contains("skip_serializing_if = \"Option::is_none\""), "{out}");
1598        assert!(out.contains("pub note: Option<String>"), "{out}");
1599    }
1600}