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