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) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => {
353            visit_type(inner, visit);
354        }
355        leaf => visit(leaf),
356    }
357}
358
359/// Rewrite the `Named` references reachable from a single module item.
360fn rewrite_item(item: &mut Item, renames: &HashMap<String, String>) {
361    match item {
362        Item::Struct(strukt) => {
363            for field in &mut strukt.fields {
364                rewrite_type(&mut field.ty, renames);
365            }
366            if let Some(additional) = &mut strukt.additional_properties {
367                rewrite_type(additional, renames);
368            }
369        }
370        Item::Enum(enumeration) => {
371            if let EnumKind::Union(variants) = &mut enumeration.kind {
372                for variant in variants {
373                    rewrite_type(&mut variant.ty, renames);
374                }
375            }
376        }
377        Item::Alias(alias) => rewrite_type(&mut alias.ty, renames),
378    }
379}
380
381/// Replace a `Named(old)` with `Named(new)` (recursing through containers).
382fn rewrite_type(ty: &mut RustType, renames: &HashMap<String, String>) {
383    match ty {
384        RustType::Named(name) => {
385            if let Some(custom) = renames.get(name.as_str()) {
386                *name = custom.clone();
387            }
388        }
389        RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => {
390            rewrite_type(inner, renames);
391        }
392        _ => {}
393    }
394}
395/// Fail generation if two items of `module` take one Rust type name.
396///
397/// [`TypeNames::check_emitted`] covers two component schemas that collapse onto
398/// one identifier. It cannot cover a hoisted inline schema, because such a schema
399/// has no name in `components` for the resolution pass to see. Lowering names a
400/// hoisted item after the property path that encloses it, so a component schema
401/// named after that same path takes the same name. The emitted item is what
402/// decides, so this check reads the final item names.
403///
404/// Every generation mode calls this check, including models-only generation.
405///
406/// # Errors
407///
408/// Returns one [`Error::DuplicateTypeName`] for a single duplicate name, or an
409/// [`Error::Validation`] that holds all of them.
410pub fn check_duplicate_models(module: &Module) -> Result<()> {
411    let mut diagnostics = crate::lower::validate::Diagnostics::new();
412    let mut seen: HashSet<&str> = HashSet::new();
413    for item in &module.items {
414        if !seen.insert(item.name()) {
415            diagnostics.push(Error::DuplicateTypeName {
416                name: item.name().to_owned(),
417                hint: duplicate_model_hint(item.name()),
418            });
419        }
420    }
421    return diagnostics.into_result();
422}
423
424/// Fail generation if an emitted item takes the name of a prelude type that the
425/// file names without a path.
426///
427/// This is not a duplicate-name check. A schema named `Option` emits one item,
428/// so [`check_duplicate_models`] and [`check_type_name_collisions`] both pass.
429/// The item shadows `Option` for the whole file instead, and every `Option<T>`
430/// in it then reads as that struct.
431///
432/// Every generation mode calls this check. `targets` says which names to hold,
433/// because only a server or a client writes `Result`.
434///
435/// The check reads names, not uses, so it is wider than it has to be. A spec
436/// with a schema named `Box` and no recursion writes no `Box<T>`, and it would
437/// compile. It is still rejected. Two reasons keep it that way: a false
438/// rejection is loud and has a one-line remedy in the hint, while a missed use
439/// site emits code that does not compile, which is the failure this check
440/// exists to stop. The verdict also stays put. Adding a recursive schema later
441/// cannot turn an accepted name into a broken build.
442///
443/// # Errors
444///
445/// Returns one [`Error::PreludeShadowing`] for a single name, or an
446/// [`Error::Validation`] that holds all of them.
447pub fn check_prelude_shadowing(module: &Module, targets: Targets) -> Result<()> {
448    let mut diagnostics = crate::lower::validate::Diagnostics::new();
449    let prelude = crate::emit::prelude_type_names(targets);
450    for item in &module.items {
451        let Some(shadowed) = prelude.iter().find(|entry| return entry.name == item.name()) else {
452            continue;
453        };
454        diagnostics.push(Error::PreludeShadowing {
455            name: shadowed.name.to_owned(),
456            used_for: shadowed.used_for.to_owned(),
457            hint: format!("Rename the schema with `{X_RUST_NAME}`, or with `output-options.type-name-suffix`."),
458        });
459    }
460    return diagnostics.into_result();
461}
462
463/// What claimed one crate-root type name.
464enum Claim {
465    /// A component model, or an inline schema that lowering hoisted to the crate
466    /// root. The two are one case here, because the emitted item is the same kind
467    /// of item and the remedy is the same.
468    Model,
469    /// A fixed type name that a requested generator interface emits, for example
470    /// the `Api` trait. The payload describes what emits it.
471    Reserved(&'static str),
472    /// A per-operation type. Every such name derives from the method name of the
473    /// operation that produced it, so the remedy names that operation.
474    Artifact {
475        /// What the generator emits, for example `query-parameter struct`.
476        kind: &'static str,
477        /// The `Api` method name of the operation that produced it.
478        operation: String,
479    },
480}
481
482/// Fail generation if two items that the file holds take one Rust type name.
483///
484/// In the flat layout, component models, inline schemas that lowering hoists to
485/// the crate root, per-operation types (response enums, parameter structs, and
486/// request/response body enums), and the requested generator interfaces
487/// (`reserved`, for example the `Api` trait or the `Client` struct) all share the
488/// crate root. Any two of them that take one name emit two items with that name,
489/// which does not compile. The check therefore holds one namespace and reports
490/// every claim that some earlier claim already took. Four cases reach it.
491///
492/// * A model against a reserved name. A component schema or a hoisted inline
493///   schema named `Api` or `Client` is the case.
494/// * A per-operation type against a model. The most common case is a schema named
495///   `<Op>Response`.
496/// * A per-operation type against a reserved name. An operation named `api` gives
497///   a response enum named `Api` when the suffix adds no characters.
498/// * A per-operation type against another per-operation type. A
499///   `response-type-suffix` that matches a parameter-struct suffix is one way to
500///   reach this, because it makes one operation's response enum take the name of
501///   a parameter struct.
502///
503/// Two models that take one name are the fifth pair in this namespace, and
504/// [`check_duplicate_models`] reports them. This check seeds the namespace with
505/// every model name and reports nothing for a repeat, so one clash gives one
506/// problem. That split needs the caller to run [`check_duplicate_models`] as
507/// well, which every generation mode does.
508///
509/// Rather than rename an item, generation fails, so the author resolves the clash.
510/// The `hint` of each problem names the remedy for that case.
511///
512/// Only locally emitted models count. An import-mapped model is referenced
513/// through a qualified path and cannot collide with a crate-root type.
514///
515/// # Errors
516///
517/// Returns one collision error for a single clash, or an [`Error::Validation`]
518/// that holds all of them. One run therefore reports every clash it finds.
519pub fn check_type_name_collisions(service: &Service, module: &Module, reserved: &[ReservedTypeName]) -> Result<()> {
520    let mut diagnostics = crate::lower::validate::Diagnostics::new();
521    // Seeding records each model name and reports nothing for a repeat, because
522    // `check_duplicate_models` owns that pair. This check does not enforce that the
523    // caller runs it. A caller that skips it emits two items with one name and no
524    // error, so every generation mode must call both.
525    let mut claimed: HashMap<String, Claim> = module
526        .items
527        .iter()
528        .map(|item| return (item.name().to_owned(), Claim::Model))
529        .collect();
530    for name in reserved {
531        // A model that took a reserved name keeps its `Claim::Model` entry, so the
532        // problem reports once here and not again for the reserved claim.
533        match claimed.insert(name.name.to_owned(), Claim::Reserved(name.description)) {
534            Some(Claim::Model) => {
535                claimed.insert(name.name.to_owned(), Claim::Model);
536                diagnostics.push(Error::TypeNameCollision {
537                    name: name.name.to_owned(),
538                    artifact: name.description.to_owned(),
539                    hint: format!("rename the schema with `{X_RUST_NAME}`"),
540                });
541            }
542            Some(Claim::Reserved(_) | Claim::Artifact { .. }) | None => {}
543        }
544    }
545    for operation in &service.operations {
546        let mut claim = |name: &RustIdent, kind: &'static str| {
547            claim_artifact(&mut claimed, &mut diagnostics, name, kind, &operation.name);
548        };
549        claim(&operation.response_enum, "response enum");
550        if let Some(query) = &operation.query {
551            claim(&query.name, "query-parameter struct");
552        }
553        if let Some(headers) = &operation.headers {
554            claim(&headers.name, "header-parameter struct");
555        }
556        if let Some(cookies) = &operation.cookies {
557            claim(&cookies.name, "cookie-parameter struct");
558        }
559        match &operation.request {
560            Some(RequestPayload::Multipart(multipart)) => claim(&multipart.name, "multipart request struct"),
561            Some(RequestPayload::Negotiated(request)) => claim(&request.name, "request-body enum"),
562            Some(RequestPayload::Single(_)) | None => {}
563        }
564        for response in &operation.responses {
565            if let Some(ResponseBody::Negotiated(body)) = &response.body {
566                claim(&body.name, "response-body enum");
567            }
568        }
569    }
570    return diagnostics.into_result();
571}
572
573/// Record one per-operation type name in `claimed`, or report the clash when some
574/// earlier claim already took it.
575fn claim_artifact(
576    claimed: &mut HashMap<String, Claim>,
577    diagnostics: &mut crate::lower::validate::Diagnostics,
578    name: &RustIdent,
579    kind: &'static str,
580    operation: &RustIdent,
581) {
582    let ident = name.logical();
583    match claimed.get(ident) {
584        None => {
585            claimed.insert(
586                ident.to_owned(),
587                Claim::Artifact {
588                    kind,
589                    operation: operation.logical().to_owned(),
590                },
591            );
592        }
593        Some(Claim::Model) => {
594            diagnostics.push(Error::TypeNameCollision {
595                name: ident.to_owned(),
596                artifact: kind.to_owned(),
597                hint: model_clash_hint(kind),
598            });
599        }
600        Some(Claim::Reserved(description)) => {
601            diagnostics.push(Error::OperationTypeCollision {
602                name: ident.to_owned(),
603                first: format!("the {description}"),
604                second: format!("the {kind} of operation `{}`", operation.logical()),
605                hint: reserved_clash_hint(operation.logical()),
606            });
607        }
608        Some(Claim::Artifact {
609            kind: first_kind,
610            operation: first_operation,
611        }) => {
612            diagnostics.push(Error::OperationTypeCollision {
613                name: ident.to_owned(),
614                first: format!("the {first_kind} of operation `{first_operation}`"),
615                second: format!("the {kind} of operation `{}`", operation.logical()),
616                hint: artifact_clash_hint(first_operation, operation.logical()),
617            });
618        }
619    }
620}
621
622/// The remedy for two models that take one name.
623///
624/// At least one of the two is a hoisted inline schema, which carries no name of
625/// its own to override. The remedy therefore acts on the component schema that
626/// encloses it, or removes the hoist by giving the inline schema a component of
627/// its own.
628fn duplicate_model_hint(name: &str) -> String {
629    return format!(
630        "One of these comes from an inline schema that the generator hoists to the crate root, and \
631         an inline schema carries no name to override. Give the enclosing component schema a \
632         different Rust name with `{X_RUST_NAME}`, or move the inline schema into its own component \
633         schema, name that component something other than `{name}`, and refer to it with `$ref`.",
634    );
635}
636
637/// The remedy for a per-operation type that takes the name of an emitted model.
638///
639/// A response-enum clash has a second remedy, because one config key renames
640/// every response enum. The hint leads with the per-schema `x-rust-name` fix,
641/// which leaves the other response enums untouched, and offers the broad suffix
642/// after it.
643fn model_clash_hint(kind: &str) -> String {
644    if kind != "response enum" {
645        return format!("rename the schema with `{X_RUST_NAME}`");
646    }
647    let default_suffix = to_ident(DEFAULT_RESPONSE_SUFFIX, Case::Pascal);
648    return format!(
649        "give the colliding schema a different Rust name with `{X_RUST_NAME}` — a surgical, \
650         per-schema fix that leaves the other response enums untouched — or, to rename every \
651         response enum, set `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` to a suffix other \
652         than the default `{}` (for example `{RESPONSE_TYPE_SUFFIX_KEY}: Resp`, which renames \
653         the enum to `<Op>Resp`)",
654        default_suffix.logical(),
655    );
656}
657
658/// The remedy for a per-operation type that takes the name of a generator
659/// interface.
660///
661/// The interface name is fixed, so only the operation side can move. Every
662/// per-operation type derives from the method name, so `x-rust-name` on the
663/// operation resolves the clash. A configured suffix can also produce this name,
664/// which the second remedy covers.
665fn reserved_clash_hint(operation: &str) -> String {
666    return format!(
667        "The generator emits this name for a requested target, so the name cannot move. Give \
668         operation `{operation}` a different method name with `{X_RUST_NAME}`, or change \
669         `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` if that suffix produced the name.",
670    );
671}
672
673/// The remedy for two per-operation types that take one name.
674///
675/// One operation that produces both names is a different problem from two
676/// operations that produce one name. No method name can separate two types of one
677/// operation, so that case names the suffix that made them equal. Two operations
678/// take the per-operation `x-rust-name` remedy, because every per-operation type
679/// derives from the method name.
680fn artifact_clash_hint(first_operation: &str, second_operation: &str) -> String {
681    if first_operation == second_operation {
682        return format!(
683            "Both names belong to operation `{first_operation}`, so no method name can separate \
684             them. Set `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` to a suffix that no \
685             parameter-struct or body-enum name already ends with.",
686        );
687    }
688    return format!(
689        "Every per-operation type derives from the method name of its operation. Give operation \
690         `{first_operation}` or operation `{second_operation}` a different method name with \
691         `{X_RUST_NAME}`.",
692    );
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn suffix_with_identifier_characters_is_accepted() {
701        for suffix in ["Alt", "2", "a", "-v2", "_alt"] {
702            let outcome = checked_suffix(Some(suffix));
703            assert!(
704                matches!(outcome, Ok(Some(kept)) if kept == suffix),
705                "`{suffix}` adds characters to a type name and must be accepted",
706            );
707        }
708    }
709
710    #[test]
711    fn absent_suffix_stays_absent() {
712        assert!(matches!(checked_suffix(None), Ok(None)));
713    }
714
715    #[test]
716    fn suffix_without_identifier_characters_is_rejected() {
717        // Casing drops each of these, so the suffix cannot resolve a collision.
718        // An unbounded search for a free name would otherwise never end.
719        for suffix in ["", " ", "-", "_", "...", "-_-"] {
720            let outcome = checked_suffix(Some(suffix));
721            assert!(
722                matches!(outcome, Err(Error::InvalidTypeNameSuffix { .. })),
723                "`{suffix}` adds nothing to a type name and must be rejected",
724            );
725        }
726    }
727
728    #[test]
729    fn rejected_suffix_names_both_remedies() {
730        let Err(err) = checked_suffix(Some("-")) else {
731            panic!("`-` must be rejected");
732        };
733        let Error::InvalidTypeNameSuffix { hint, .. } = &err else {
734            panic!("expected an InvalidTypeNameSuffix, got {err:?}");
735        };
736        // The hint must show how to make the suffix work, and how to go back to
737        // an error for a collision.
738        assert!(hint.contains(TYPE_NAME_SUFFIX_KEY), "hint names the key: {hint}");
739        assert!(hint.contains("Alt"), "hint gives a working example: {hint}");
740        assert!(hint.contains("remove"), "hint offers the error mode: {hint}");
741        // The message states the problem. The console prints the hint under it,
742        // so `Display` must not repeat the hint.
743        assert!(!err.to_string().contains(hint.as_str()));
744    }
745
746    #[test]
747    fn suffixed_ident_grows_until_the_name_is_free() {
748        let mut claimed: HashMap<String, String> = HashMap::new();
749        claimed.insert("OrderItem".to_owned(), "order-item".to_owned());
750        claimed.insert("OrderItemAlt".to_owned(), "orderItem".to_owned());
751        // `OrderItem` and `OrderItemAlt` are taken, so a third collision must
752        // repeat the suffix instead of reusing `OrderItemAlt`.
753        let ident = suffixed_ident(&to_ident("order-item", Case::Pascal), "Alt", &claimed);
754        assert_eq!(ident.logical(), "OrderItemAltAlt");
755    }
756}