Skip to main content

oapi_codegen/lower/
rename.rs

1//! Resolution of top-level type names across the lowered IR.
2//!
3//! A generated type name differs from the plain `to_ident(schema_name)` for two
4//! reasons. The schema carries an `x-rust-name` override, or two schema names
5//! collapse onto one Rust identifier and the config supplies a suffix. Every
6//! reference to a schema lowers to a [`RustType::Named`] that holds the original
7//! schema name. Name resolution therefore must also rewrite those references to
8//! point at the final identifier. Lowering sets the item names from the same
9//! resolution map. See [`crate::lower::schema`]. This pass rewrites the `Named`
10//! references that still point at the original name.
11//!
12//! An unresolved collision is not reported here. Default pruning drops a schema
13//! that no operation uses, and a collision between two dropped schemas never
14//! reaches the output. [`type_renames`] therefore returns the collisions it found,
15//! and the caller reports them when the set of emitted models is final.
16
17use std::collections::HashMap;
18use std::collections::HashSet;
19
20use openapiv3::ReferenceOr;
21
22use crate::config::DEFAULT_RESPONSE_SUFFIX;
23use crate::config::OUTPUT_OPTIONS_KEY;
24use crate::config::RESPONSE_TYPE_SUFFIX_KEY;
25use crate::config::TYPE_NAME_SUFFIX_KEY;
26use crate::emit::ReservedTypeName;
27use crate::emit::Targets;
28use crate::error::Error;
29use crate::error::Result;
30use crate::ir::EnumKind;
31use crate::ir::Item;
32use crate::ir::Module;
33use crate::ir::RequestPayload;
34use crate::ir::ResponseBody;
35use crate::ir::RustType;
36use crate::ir::Service;
37use crate::loader::Spec;
38use crate::naming::Case;
39use crate::naming::RustIdent;
40use crate::naming::X_RUST_NAME;
41use crate::naming::to_ident;
42
43/// The final Rust type name of every top-level schema, together with each
44/// collision that still needs a decision from the author.
45///
46/// A collision is not an error at resolution time. Default pruning drops a schema
47/// that no generated operation uses, and two dropped schemas that collapse onto
48/// one Rust identifier cause no problem in the output. The caller therefore holds
49/// this value until the module is final and then calls [`TypeNames::check_emitted`].
50#[derive(Debug)]
51pub struct TypeNames {
52    /// A map from an original schema name to the final Rust type identifier.
53    renames: HashMap<String, String>,
54    /// Every collision found, in document order.
55    collisions: Vec<Collision>,
56}
57
58/// Two schema names that collapse onto one Rust identifier, and no suffix to
59/// tell them apart. The fields are kept instead of a built [`Error`], because the
60/// caller decides later whether this collision reaches the output at all.
61#[derive(Debug)]
62struct Collision {
63    /// The Rust identifier that both schemas produce.
64    ident: String,
65    /// The schema that claimed `ident` first.
66    first: String,
67    /// The schema that collided with `first`.
68    second: String,
69    /// Whether `second` carries an `x-rust-name`, which changes the remedy.
70    overridden: bool,
71}
72
73impl TypeNames {
74    /// The renames to apply to every `Named` reference and item name.
75    ///
76    /// The map holds one entry for each top-level schema whose emitted name
77    /// differs from the plain `to_ident(name)`. Two sources add an entry:
78    ///
79    /// * an `x-rust-name` override (inline schemas only), and
80    /// * a collision suffix from `output-options.type-name-suffix`, added to the
81    ///   second of two schema names that collapse onto one Rust identifier.
82    ///
83    /// The map omits a schema whose emitted name does not change. The common case
84    /// therefore gives an empty map, and the rewrite passes do no work.
85    ///
86    /// An unresolved collision adds no entry, so both schemas keep the plain
87    /// name. The module then holds two items with that one name, which is why an
88    /// unchecked collision must never reach the emitter.
89    pub fn renames(&self) -> &HashMap<String, String> {
90        return &self.renames;
91    }
92
93    /// Report a collision whose Rust type name `module` still holds, and collect
94    /// every such collision into one result.
95    ///
96    /// The emitted name is what decides. Two items with one name do not compile,
97    /// whatever schema each item came from, and a schema that `module` does not
98    /// hold emits no item at all. The check is therefore keyed on the identifier
99    /// and not on the provenance of the two schemas.
100    ///
101    /// Keying on the identifier also covers a name that a hoisted inline type
102    /// takes. Pruning is name-based (see [`crate::lower::prune`]), so an inline
103    /// item named `FooBar` keeps two unused `foo-bar` and `fooBar` components
104    /// alive. Three items then share one name. The collision is real there, even
105    /// though no operation reaches either component.
106    ///
107    /// A caller that prunes nothing gets a module that holds every schema, so
108    /// every collision reports.
109    ///
110    /// # Errors
111    ///
112    /// Returns one [`Error::SchemaNameCollision`] for a single surviving
113    /// collision, or an [`Error::Validation`] that holds all of them.
114    pub fn check_emitted(&self, module: &Module) -> Result<()> {
115        let emitted: HashSet<&str> = module.items.iter().map(|item| return item.name()).collect();
116        let mut diagnostics = crate::lower::validate::Diagnostics::new();
117        for collision in &self.collisions {
118            if !emitted.contains(collision.ident.as_str()) {
119                continue;
120            }
121            diagnostics.push(Error::SchemaNameCollision {
122                ident: collision.ident.clone(),
123                first: collision.first.clone(),
124                second: collision.second.clone(),
125                hint: collision_hint(&collision.ident, &collision.second, collision.overridden),
126            });
127        }
128        return diagnostics.into_result();
129    }
130}
131
132/// Resolve the Rust type name of every top-level schema in `spec`.
133///
134/// Two distinct schema names can collapse onto one Rust identifier. For example,
135/// `foo-bar` and `fooBar` both become `FooBar`. When `suffix` is set, the second
136/// name takes the suffix. When `suffix` is `None`, the collision is recorded in
137/// the returned [`TypeNames`] for the caller to report, because the generator
138/// will not choose a name for one of two distinct schemas. That choice belongs to
139/// the author.
140///
141/// # Errors
142///
143/// A `suffix` that contributes no characters to an identifier is an error.
144/// Casing removes punctuation, so a suffix such as `-` leaves the name unchanged
145/// and cannot resolve a collision. A valid suffix holds a letter or a digit.
146pub fn type_renames(spec: &Spec, suffix: Option<&str>) -> Result<TypeNames> {
147    let suffix = checked_suffix(suffix)?;
148    let mut resolved = HashMap::new();
149    let mut collisions = Vec::new();
150    // Maps a claimed identifier back to the schema name that claimed it, so a
151    // collision error can name the earlier schema and not the identifier alone.
152    let mut claimed: HashMap<String, String> = HashMap::new();
153    for (name, entry) in spec.schemas() {
154        let override_name = match entry {
155            ReferenceOr::Item(schema) => {
156                crate::lower::extension::str_value(&schema.schema_data.extensions, X_RUST_NAME, name)?
157            }
158            ReferenceOr::Reference { .. } => None,
159        };
160        let effective = override_name.unwrap_or(name);
161        let mut ident = to_ident(effective, Case::Pascal);
162        if let Some(first) = claimed.get(ident.logical()) {
163            match suffix {
164                Some(suffix) => {
165                    ident = suffixed_ident(&ident, suffix, &claimed);
166                }
167                None => {
168                    collisions.push(Collision {
169                        ident: ident.logical().to_owned(),
170                        first: first.clone(),
171                        second: name.clone(),
172                        overridden: override_name.is_some(),
173                    });
174                    continue;
175                }
176            }
177        }
178        claimed.insert(ident.logical().to_owned(), name.clone());
179        if ident.logical() != to_ident(name, Case::Pascal).logical() {
180            resolved.insert(name.clone(), ident.logical().to_owned());
181        }
182    }
183    return Ok(TypeNames {
184        renames: resolved,
185        collisions,
186    });
187}
188
189/// Reject a configured suffix that adds nothing to a Rust type name.
190///
191/// Casing drops punctuation and separators. `to_ident("Foo -")` therefore gives
192/// `Foo` again, and the same holds for an empty suffix. Such a suffix cannot
193/// resolve a collision, because the second name stays the same as the first.
194/// [`suffixed_ident`] would search for a free name that it can never produce.
195///
196/// The check runs one time for the whole document, and not for each schema,
197/// because the suffix comes from the config and does not change per schema.
198///
199/// This returns an error and does not treat the suffix as unset. A silent
200/// fallback would report a collision and tell the author to set
201/// `type-name-suffix`, which the author already did.
202fn checked_suffix(suffix: Option<&str>) -> Result<Option<&str>> {
203    let Some(suffix) = suffix else {
204        return Ok(None);
205    };
206    // Compare against a fixed stem, because the result must hold for every name.
207    // A suffix that adds characters to one name adds them to all names.
208    const STEM: &str = "Placeholder";
209    if to_ident(&format!("{STEM} {suffix}"), Case::Pascal).logical() != STEM {
210        return Ok(Some(suffix));
211    }
212    return Err(Error::InvalidTypeNameSuffix {
213        suffix: suffix.to_owned(),
214        hint: format!(
215            "Casing removes punctuation and separators, so `{suffix}` leaves the type name unchanged. \
216             Use a suffix with at least one letter or digit (for example \
217             `{TYPE_NAME_SUFFIX_KEY}: Alt`). To make a collision an error instead, remove \
218             `{OUTPUT_OPTIONS_KEY}.{TYPE_NAME_SUFFIX_KEY}`.",
219        ),
220    });
221}
222
223/// Add `suffix` to `ident`, and keep adding it until the result is free.
224///
225/// Repetition matters for a three-way collision. Two schemas already hold
226/// `Widget` and `WidgetAlt`, so a third must not take `WidgetAlt` again.
227///
228/// The loop ends because `suffix` adds at least one character to the identifier,
229/// which [`checked_suffix`] guarantees. Each pass therefore gives a longer name,
230/// and the supply of unclaimed names cannot run out.
231fn suffixed_ident(ident: &RustIdent, suffix: &str, claimed: &HashMap<String, String>) -> RustIdent {
232    let mut candidate = to_ident(&format!("{} {suffix}", ident.logical()), Case::Pascal);
233    while claimed.contains_key(candidate.logical()) {
234        let longer = to_ident(&format!("{} {suffix}", candidate.logical()), Case::Pascal);
235        // Defensive: `checked_suffix` rules this out. Growth is the reason the
236        // loop ends, so a non-growing step would spin forever. Stop instead.
237        if longer.logical() == candidate.logical() {
238            return candidate;
239        }
240        candidate = longer;
241    }
242    return candidate;
243}
244
245/// Build the remedy text for a schema-name collision.
246///
247/// `ident` is the Rust identifier that both schemas produce, so the example names
248/// real types and not spec names. `second` is the schema that collided.
249/// `overridden` records whether `second` already carries an `x-rust-name`. That
250/// case needs different advice, because the override itself caused the collision.
251fn collision_hint(ident: &str, second: &str, overridden: bool) -> String {
252    if overridden {
253        return format!(
254            "`{second}` already sets `{X_RUST_NAME}`, and that name also resolves to `{ident}`. \
255             Give `{second}` a name that no other schema uses.",
256        );
257    }
258    return format!(
259        "Give one of the two schemas a different Rust name with `{X_RUST_NAME}`, which records the \
260         type name the author wants. To rename every later collision instead, set \
261         `{OUTPUT_OPTIONS_KEY}.{TYPE_NAME_SUFFIX_KEY}` (for example `{TYPE_NAME_SUFFIX_KEY}: Alt`, \
262         which emits `{ident}` and `{ident}Alt`).",
263    );
264}
265
266/// Rewrite every `Named` reference in `module`'s items to honour `renames`.
267pub fn rewrite_module(module: &mut Module, renames: &HashMap<String, String>) {
268    if renames.is_empty() {
269        return;
270    }
271    for item in &mut module.items {
272        rewrite_item(item, renames);
273    }
274}
275
276/// Rewrite every `Named` reference in `service`'s operations to honour `renames`.
277pub fn rewrite_service(service: &mut Service, renames: &HashMap<String, String>) {
278    if renames.is_empty() {
279        return;
280    }
281    visit_service_types(service, &mut |ty| {
282        if let RustType::Named(name) = ty
283            && let Some(custom) = renames.get(name.as_str())
284        {
285            *name = custom.clone();
286        }
287    });
288}
289
290/// Apply `visit` to every leaf [`RustType`] referenced by the service's
291/// operation signatures (path/query/header/cookie params, request and response
292/// bodies, and response headers). Container types (`Vec`/`Map`/`Option`) are
293/// traversed to their leaf; `visit` receives the leaf in place.
294fn visit_service_types(service: &mut Service, visit: &mut dyn FnMut(&mut RustType)) {
295    for operation in &mut service.operations {
296        for param in &mut operation.path_params {
297            visit_type(&mut param.ty, visit);
298        }
299        if let Some(query) = &mut operation.query {
300            for field in &mut query.fields {
301                visit_type(&mut field.ty, visit);
302            }
303            if let Some(additional) = &mut query.additional_properties {
304                visit_type(additional, visit);
305            }
306        }
307        if let Some(headers) = &mut operation.headers {
308            for param in &mut headers.params {
309                visit_type(&mut param.ty, visit);
310            }
311        }
312        if let Some(cookies) = &mut operation.cookies {
313            for param in &mut cookies.params {
314                visit_type(&mut param.ty, visit);
315            }
316        }
317        if let Some(request) = &mut operation.request {
318            match request {
319                RequestPayload::Single(body) => visit_type(&mut body.ty, visit),
320                RequestPayload::Multipart(multipart) => {
321                    for field in &mut multipart.fields {
322                        visit_type(&mut field.ty, visit);
323                    }
324                }
325                RequestPayload::Negotiated(negotiated) => {
326                    for variant in &mut negotiated.variants {
327                        visit_type(&mut variant.body.ty, visit);
328                    }
329                }
330            }
331        }
332        for response in &mut operation.responses {
333            match &mut response.body {
334                Some(ResponseBody::Single(body)) => visit_type(&mut body.ty, visit),
335                Some(ResponseBody::Negotiated(negotiated)) => {
336                    for variant in &mut negotiated.variants {
337                        visit_type(&mut variant.body.ty, visit);
338                    }
339                }
340                None => {}
341            }
342            for header in &mut response.headers {
343                visit_type(&mut header.ty, visit);
344            }
345        }
346    }
347}
348
349/// Recurse container types to their leaf, applying `visit` to the leaf in place.
350fn visit_type(ty: &mut RustType, visit: &mut dyn FnMut(&mut RustType)) {
351    match ty {
352        RustType::Vec(inner)
353        | RustType::Map(inner)
354        | RustType::Option(inner)
355        | RustType::Nullable(inner)
356        | RustType::Boxed(inner) => {
357            visit_type(inner, visit);
358        }
359        leaf => visit(leaf),
360    }
361}
362
363/// Rewrite the `Named` references reachable from a single module item.
364fn rewrite_item(item: &mut Item, renames: &HashMap<String, String>) {
365    match item {
366        Item::Struct(strukt) => {
367            for field in &mut strukt.fields {
368                rewrite_type(&mut field.ty, renames);
369            }
370            if let Some(additional) = &mut strukt.additional_properties {
371                rewrite_type(additional, renames);
372            }
373        }
374        Item::Enum(enumeration) => {
375            if let EnumKind::Union(variants) | EnumKind::AnyOf(variants) = &mut enumeration.kind {
376                for variant in variants {
377                    rewrite_type(&mut variant.ty, renames);
378                }
379            }
380        }
381        Item::Alias(alias) => rewrite_type(&mut alias.ty, renames),
382    }
383}
384
385/// Replace a `Named(old)` with `Named(new)` (recursing through containers).
386fn rewrite_type(ty: &mut RustType, renames: &HashMap<String, String>) {
387    match ty {
388        RustType::Named(name) => {
389            if let Some(custom) = renames.get(name.as_str()) {
390                *name = custom.clone();
391            }
392        }
393        RustType::Vec(inner)
394        | RustType::Map(inner)
395        | RustType::Option(inner)
396        | RustType::Nullable(inner)
397        | RustType::Boxed(inner) => {
398            rewrite_type(inner, renames);
399        }
400        _ => {}
401    }
402}
403/// Fail generation if two items of `module` take one Rust type name.
404///
405/// [`TypeNames::check_emitted`] covers two component schemas that collapse onto
406/// one identifier. It cannot cover a hoisted inline schema, because such a schema
407/// has no name in `components` for the resolution pass to see. Lowering names a
408/// hoisted item after the property path that encloses it, so a component schema
409/// named after that same path takes the same name. The emitted item is what
410/// decides, so this check reads the final item names.
411///
412/// Every generation mode calls this check, including models-only generation.
413///
414/// # Errors
415///
416/// Returns one [`Error::DuplicateTypeName`] for a single duplicate name, or an
417/// [`Error::Validation`] that holds all of them.
418pub fn check_duplicate_models(module: &Module) -> Result<()> {
419    let mut diagnostics = crate::lower::validate::Diagnostics::new();
420    let mut seen: HashSet<&str> = HashSet::new();
421    for item in &module.items {
422        if !seen.insert(item.name()) {
423            diagnostics.push(Error::DuplicateTypeName {
424                name: item.name().to_owned(),
425                hint: duplicate_model_hint(item.name()),
426            });
427        }
428    }
429    return diagnostics.into_result();
430}
431
432/// Fail generation if an emitted item takes the name of a prelude type that the
433/// file names without a path.
434///
435/// This is not a duplicate-name check. A schema named `Option` emits one item,
436/// so [`check_duplicate_models`] and [`check_type_name_collisions`] both pass.
437/// The item shadows `Option` for the whole file instead, and every `Option<T>`
438/// in it then reads as that struct.
439///
440/// Every generation mode calls this check. `targets` says which names to hold,
441/// because only a server or a client writes `Result`.
442///
443/// The check reads names, not uses, so it is wider than it has to be. A spec
444/// with a schema named `Box` and no recursion writes no `Box<T>`, and it would
445/// compile. It is still rejected. Two reasons keep it that way: a false
446/// rejection is loud and has a one-line remedy in the hint, while a missed use
447/// site emits code that does not compile, which is the failure this check
448/// exists to stop. The verdict also stays put. Adding a recursive schema later
449/// cannot turn an accepted name into a broken build.
450///
451/// # Errors
452///
453/// Returns one [`Error::PreludeShadowing`] for a single name, or an
454/// [`Error::Validation`] that holds all of them.
455pub fn check_prelude_shadowing(module: &Module, targets: Targets) -> Result<()> {
456    let mut diagnostics = crate::lower::validate::Diagnostics::new();
457    let prelude = crate::emit::prelude_type_names(targets);
458    for item in &module.items {
459        let Some(shadowed) = prelude.iter().find(|entry| return entry.name == item.name()) else {
460            continue;
461        };
462        diagnostics.push(Error::PreludeShadowing {
463            name: shadowed.name.to_owned(),
464            used_for: shadowed.used_for.to_owned(),
465            hint: format!("Rename the schema with `{X_RUST_NAME}`, or with `output-options.type-name-suffix`."),
466        });
467    }
468    return diagnostics.into_result();
469}
470
471/// What claimed one crate-root type name.
472enum Claim {
473    /// A component model, or an inline schema that lowering hoisted to the crate
474    /// root. The two are one case here, because the emitted item is the same kind
475    /// of item and the remedy is the same.
476    Model,
477    /// A fixed type name that a requested generator interface emits, for example
478    /// the `Api` trait. The payload describes what emits it.
479    Reserved(&'static str),
480    /// A per-operation type. Every such name derives from the method name of the
481    /// operation that produced it, so the remedy names that operation.
482    Artifact {
483        /// What the generator emits, for example `query-parameter struct`.
484        kind: &'static str,
485        /// The `Api` method name of the operation that produced it.
486        operation: String,
487    },
488}
489
490/// Fail generation if two items that the file holds take one Rust type name.
491///
492/// In the flat layout, component models, inline schemas that lowering hoists to
493/// the crate root, per-operation types (response enums, parameter structs, and
494/// request/response body enums), and the requested generator interfaces
495/// (`reserved`, for example the `Api` trait or the `Client` struct) all share the
496/// crate root. Any two of them that take one name emit two items with that name,
497/// which does not compile. The check therefore holds one namespace and reports
498/// every claim that some earlier claim already took. Four cases reach it.
499///
500/// * A model against a reserved name. A component schema or a hoisted inline
501///   schema named `Api` or `Client` is the case.
502/// * A per-operation type against a model. The most common case is a schema named
503///   `<Op>Response`.
504/// * A per-operation type against a reserved name. An operation named `api` gives
505///   a response enum named `Api` when the suffix adds no characters.
506/// * A per-operation type against another per-operation type. A
507///   `response-type-suffix` that matches a parameter-struct suffix is one way to
508///   reach this, because it makes one operation's response enum take the name of
509///   a parameter struct.
510///
511/// Two models that take one name are the fifth pair in this namespace, and
512/// [`check_duplicate_models`] reports them. This check seeds the namespace with
513/// every model name and reports nothing for a repeat, so one clash gives one
514/// problem. That split needs the caller to run [`check_duplicate_models`] as
515/// well, which every generation mode does.
516///
517/// Rather than rename an item, generation fails, so the author resolves the clash.
518/// The `hint` of each problem names the remedy for that case.
519///
520/// Only locally emitted models count. An import-mapped model is referenced
521/// through a qualified path and cannot collide with a crate-root type.
522///
523/// # Errors
524///
525/// Returns one collision error for a single clash, or an [`Error::Validation`]
526/// that holds all of them. One run therefore reports every clash it finds.
527pub fn check_type_name_collisions(service: &Service, module: &Module, reserved: &[ReservedTypeName]) -> Result<()> {
528    let mut diagnostics = crate::lower::validate::Diagnostics::new();
529    // Seeding records each model name and reports nothing for a repeat, because
530    // `check_duplicate_models` owns that pair. This check does not enforce that the
531    // caller runs it. A caller that skips it emits two items with one name and no
532    // error, so every generation mode must call both.
533    let mut claimed: HashMap<String, Claim> = module
534        .items
535        .iter()
536        .map(|item| return (item.name().to_owned(), Claim::Model))
537        .collect();
538    for name in reserved {
539        // A model that took a reserved name keeps its `Claim::Model` entry, so the
540        // problem reports once here and not again for the reserved claim.
541        match claimed.insert(name.name.to_owned(), Claim::Reserved(name.description)) {
542            Some(Claim::Model) => {
543                claimed.insert(name.name.to_owned(), Claim::Model);
544                diagnostics.push(Error::TypeNameCollision {
545                    name: name.name.to_owned(),
546                    artifact: name.description.to_owned(),
547                    hint: format!("rename the schema with `{X_RUST_NAME}`"),
548                });
549            }
550            Some(Claim::Reserved(_) | Claim::Artifact { .. }) | None => {}
551        }
552    }
553    for operation in &service.operations {
554        let mut claim = |name: &RustIdent, kind: &'static str| {
555            claim_artifact(&mut claimed, &mut diagnostics, name, kind, &operation.name);
556        };
557        claim(&operation.response_enum, "response enum");
558        if let Some(query) = &operation.query {
559            claim(&query.name, "query-parameter struct");
560        }
561        if let Some(headers) = &operation.headers {
562            claim(&headers.name, "header-parameter struct");
563        }
564        if let Some(cookies) = &operation.cookies {
565            claim(&cookies.name, "cookie-parameter struct");
566        }
567        match &operation.request {
568            Some(RequestPayload::Multipart(multipart)) => claim(&multipart.name, "multipart request struct"),
569            Some(RequestPayload::Negotiated(request)) => claim(&request.name, "request-body enum"),
570            Some(RequestPayload::Single(_)) | None => {}
571        }
572        for response in &operation.responses {
573            if let Some(ResponseBody::Negotiated(body)) = &response.body {
574                claim(&body.name, "response-body enum");
575            }
576        }
577    }
578    return diagnostics.into_result();
579}
580
581/// Record one per-operation type name in `claimed`, or report the clash when some
582/// earlier claim already took it.
583fn claim_artifact(
584    claimed: &mut HashMap<String, Claim>,
585    diagnostics: &mut crate::lower::validate::Diagnostics,
586    name: &RustIdent,
587    kind: &'static str,
588    operation: &RustIdent,
589) {
590    let ident = name.logical();
591    match claimed.get(ident) {
592        None => {
593            claimed.insert(
594                ident.to_owned(),
595                Claim::Artifact {
596                    kind,
597                    operation: operation.logical().to_owned(),
598                },
599            );
600        }
601        Some(Claim::Model) => {
602            diagnostics.push(Error::TypeNameCollision {
603                name: ident.to_owned(),
604                artifact: kind.to_owned(),
605                hint: model_clash_hint(kind),
606            });
607        }
608        Some(Claim::Reserved(description)) => {
609            diagnostics.push(Error::OperationTypeCollision {
610                name: ident.to_owned(),
611                first: format!("the {description}"),
612                second: format!("the {kind} of operation `{}`", operation.logical()),
613                hint: reserved_clash_hint(operation.logical()),
614            });
615        }
616        Some(Claim::Artifact {
617            kind: first_kind,
618            operation: first_operation,
619        }) => {
620            diagnostics.push(Error::OperationTypeCollision {
621                name: ident.to_owned(),
622                first: format!("the {first_kind} of operation `{first_operation}`"),
623                second: format!("the {kind} of operation `{}`", operation.logical()),
624                hint: artifact_clash_hint(first_operation, operation.logical()),
625            });
626        }
627    }
628}
629
630/// The remedy for two models that take one name.
631///
632/// At least one of the two is a hoisted inline schema, which carries no name of
633/// its own to override. The remedy therefore acts on the component schema that
634/// encloses it, or removes the hoist by giving the inline schema a component of
635/// its own.
636fn duplicate_model_hint(name: &str) -> String {
637    return format!(
638        "One of these comes from an inline schema that the generator hoists to the crate root, and \
639         an inline schema carries no name to override. Give the enclosing component schema a \
640         different Rust name with `{X_RUST_NAME}`, or move the inline schema into its own component \
641         schema, name that component something other than `{name}`, and refer to it with `$ref`.",
642    );
643}
644
645/// The remedy for a per-operation type that takes the name of an emitted model.
646///
647/// A response-enum clash has a second remedy, because one config key renames
648/// every response enum. The hint leads with the per-schema `x-rust-name` fix,
649/// which leaves the other response enums untouched, and offers the broad suffix
650/// after it.
651fn model_clash_hint(kind: &str) -> String {
652    if kind != "response enum" {
653        return format!("rename the schema with `{X_RUST_NAME}`");
654    }
655    let default_suffix = to_ident(DEFAULT_RESPONSE_SUFFIX, Case::Pascal);
656    return format!(
657        "give the colliding schema a different Rust name with `{X_RUST_NAME}` — a surgical, \
658         per-schema fix that leaves the other response enums untouched — or, to rename every \
659         response enum, set `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` to a suffix other \
660         than the default `{}` (for example `{RESPONSE_TYPE_SUFFIX_KEY}: Resp`, which renames \
661         the enum to `<Op>Resp`)",
662        default_suffix.logical(),
663    );
664}
665
666/// The remedy for a per-operation type that takes the name of a generator
667/// interface.
668///
669/// The interface name is fixed, so only the operation side can move. Every
670/// per-operation type derives from the method name, so `x-rust-name` on the
671/// operation resolves the clash. A configured suffix can also produce this name,
672/// which the second remedy covers.
673fn reserved_clash_hint(operation: &str) -> String {
674    return format!(
675        "The generator emits this name for a requested target, so the name cannot move. Give \
676         operation `{operation}` a different method name with `{X_RUST_NAME}`, or change \
677         `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` if that suffix produced the name.",
678    );
679}
680
681/// The remedy for two per-operation types that take one name.
682///
683/// One operation that produces both names is a different problem from two
684/// operations that produce one name. No method name can separate two types of one
685/// operation, so that case names the suffix that made them equal. Two operations
686/// take the per-operation `x-rust-name` remedy, because every per-operation type
687/// derives from the method name.
688fn artifact_clash_hint(first_operation: &str, second_operation: &str) -> String {
689    if first_operation == second_operation {
690        return format!(
691            "Both names belong to operation `{first_operation}`, so no method name can separate \
692             them. Set `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` to a suffix that no \
693             parameter-struct or body-enum name already ends with.",
694        );
695    }
696    return format!(
697        "Every per-operation type derives from the method name of its operation. Give operation \
698         `{first_operation}` or operation `{second_operation}` a different method name with \
699         `{X_RUST_NAME}`.",
700    );
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    #[test]
708    fn suffix_with_identifier_characters_is_accepted() {
709        for suffix in ["Alt", "2", "a", "-v2", "_alt"] {
710            let outcome = checked_suffix(Some(suffix));
711            assert!(
712                matches!(outcome, Ok(Some(kept)) if kept == suffix),
713                "`{suffix}` adds characters to a type name and must be accepted",
714            );
715        }
716    }
717
718    #[test]
719    fn absent_suffix_stays_absent() {
720        assert!(matches!(checked_suffix(None), Ok(None)));
721    }
722
723    #[test]
724    fn suffix_without_identifier_characters_is_rejected() {
725        // Casing drops each of these, so the suffix cannot resolve a collision.
726        // An unbounded search for a free name would otherwise never end.
727        for suffix in ["", " ", "-", "_", "...", "-_-"] {
728            let outcome = checked_suffix(Some(suffix));
729            assert!(
730                matches!(outcome, Err(Error::InvalidTypeNameSuffix { .. })),
731                "`{suffix}` adds nothing to a type name and must be rejected",
732            );
733        }
734    }
735
736    #[test]
737    fn rejected_suffix_names_both_remedies() {
738        let Err(err) = checked_suffix(Some("-")) else {
739            panic!("`-` must be rejected");
740        };
741        let Error::InvalidTypeNameSuffix { hint, .. } = &err else {
742            panic!("expected an InvalidTypeNameSuffix, got {err:?}");
743        };
744        // The hint must show how to make the suffix work, and how to go back to
745        // an error for a collision.
746        assert!(hint.contains(TYPE_NAME_SUFFIX_KEY), "hint names the key: {hint}");
747        assert!(hint.contains("Alt"), "hint gives a working example: {hint}");
748        assert!(hint.contains("remove"), "hint offers the error mode: {hint}");
749        // The message states the problem. The console prints the hint under it,
750        // so `Display` must not repeat the hint.
751        assert!(!err.to_string().contains(hint.as_str()));
752    }
753
754    #[test]
755    fn suffixed_ident_grows_until_the_name_is_free() {
756        let mut claimed: HashMap<String, String> = HashMap::new();
757        claimed.insert("OrderItem".to_owned(), "order-item".to_owned());
758        claimed.insert("OrderItemAlt".to_owned(), "orderItem".to_owned());
759        // `OrderItem` and `OrderItemAlt` are taken, so a third collision must
760        // repeat the suffix instead of reusing `OrderItemAlt`.
761        let ident = suffixed_ident(&to_ident("order-item", Case::Pascal), "Alt", &claimed);
762        assert_eq!(ident.logical(), "OrderItemAltAlt");
763    }
764}