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}