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