Skip to main content

oapi_codegen/lower/
schema.rs

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