@@ -459,19 +459,27 @@ const COMBINED_UNSUPPORTED_FIXTURES: &[&str] = &[
459459 "combined_reserved_name_client_error" ,
460460] ;
461461
462- /// Model-only fixtures for schema- name collisions. The golden-file tests do not
463- /// cover these, because one must fail and the other needs a config option.
462+ /// Fixtures for name collisions. The golden-file tests do not cover these,
463+ /// because some must fail and others need a config option or an extension .
464464///
465465/// `type_name_collision_error` must fail. Two schema names collapse onto one Rust
466466/// identifier, and the spec offers no remedy. `type_name_collision_suffix` must
467- /// succeed, because `output-options.type-name-suffix` resolves the collision. The
468- /// tests below exercise both.
467+ /// succeed, because `output-options.type-name-suffix` resolves the collision.
468+ ///
469+ /// The `operation_name_collision_*` fixtures do the same for a method name.
470+ /// `_error` and `_synthesised` must fail, `_rust_name` succeeds through
471+ /// `x-rust-name`, and `_filtered` succeeds because a filter removes one of the two
472+ /// operations before lowering. The tests below exercise each one.
469473const NAMING_COLLISION_FIXTURES : & [ & str ] = & [
470474 "type_name_collision_error" ,
471475 "type_name_collision_suffix" ,
472476 "type_name_collision_pruned" ,
473477 "type_name_collision_reachable" ,
474478 "type_name_collision_inline_overlap" ,
479+ "operation_name_collision_error" ,
480+ "operation_name_collision_rust_name" ,
481+ "operation_name_collision_synthesised" ,
482+ "operation_name_collision_filtered" ,
475483] ;
476484
477485/// Absolute path to the crate's `tests` directory.
@@ -1377,6 +1385,120 @@ fn collision_between_unused_schemas_fails_for_models_only() {
13771385 assert_eq ! ( problems. len( ) , 2 , "expected both collisions, got: {problems:?}" ) ;
13781386}
13791387
1388+ /// Two `operationId`s that collapse onto one Rust method name must stop
1389+ /// generation, and one run must report every collision.
1390+ ///
1391+ /// Every artifact of an operation derives from its method name, so a collision
1392+ /// emitted a duplicate trait method, response enum, and handler, and pointed both
1393+ /// routes at one handler. `rustc` rejects that with `E0428`.
1394+ #[ test]
1395+ fn colliding_operation_ids_fail ( ) {
1396+ let fixture = tests_dir ( ) . join ( "fixtures" ) . join ( "operation_name_collision_error.yaml" ) ;
1397+ let err = oapi_codegen:: generate ( & fixture, & server_config ( ) )
1398+ . expect_err ( "two operations that produce one method name must stop generation" ) ;
1399+ let oapi_codegen:: Error :: Validation { problems } = & err else {
1400+ panic ! ( "expected an aggregated Validation error, got: {err:?}" ) ;
1401+ } ;
1402+ assert_eq ! ( problems. len( ) , 2 , "expected both collisions, got: {problems:?}" ) ;
1403+ let report = problems
1404+ . iter ( )
1405+ . map ( |problem| {
1406+ return problem. to_string ( ) ;
1407+ } )
1408+ . collect :: < Vec < String > > ( )
1409+ . join ( "\n " ) ;
1410+ assert ! (
1411+ report. contains( "list_widgets" ) && report. contains( "get_thing" ) ,
1412+ "the report names both colliding method names, got: {report}" ,
1413+ ) ;
1414+ assert ! (
1415+ report. contains( "get /widgets" ) && report. contains( "get /gadgets" ) ,
1416+ "the report names the route of each colliding operation, got: {report}" ,
1417+ ) ;
1418+ }
1419+
1420+ /// The client emitter derives its names from the same lowered operation, so it
1421+ /// must reject the same collision. One check in the shared lowering pass covers
1422+ /// both emitters.
1423+ #[ test]
1424+ fn colliding_operation_ids_fail_for_the_client ( ) {
1425+ let fixture = tests_dir ( ) . join ( "fixtures" ) . join ( "operation_name_collision_error.yaml" ) ;
1426+ let err = oapi_codegen:: generate ( & fixture, & client_config ( ) )
1427+ . expect_err ( "a client run derives the same names, so the collision must stop it" ) ;
1428+ let oapi_codegen:: Error :: Validation { problems } = & err else {
1429+ panic ! ( "expected an aggregated Validation error, got: {err:?}" ) ;
1430+ } ;
1431+ assert_eq ! ( problems. len( ) , 2 , "expected both collisions, got: {problems:?}" ) ;
1432+ }
1433+
1434+ /// `x-rust-name` is the only escape hatch for an operation, and it names every
1435+ /// artifact of that operation and not the trait method alone.
1436+ #[ test]
1437+ fn x_rust_name_resolves_an_operation_collision ( ) {
1438+ let fixture = tests_dir ( )
1439+ . join ( "fixtures" )
1440+ . join ( "operation_name_collision_rust_name.yaml" ) ;
1441+ let code = oapi_codegen:: generate ( & fixture, & server_config ( ) )
1442+ . expect ( "`x-rust-name` must resolve the collision it exists for" ) ;
1443+ for expected in [
1444+ "fn list_widgets" ,
1445+ "fn list_gadgets" ,
1446+ "enum ListWidgetsResponse" ,
1447+ "enum ListGadgetsResponse" ,
1448+ "list_gadgets_handler" ,
1449+ ] {
1450+ assert ! (
1451+ code. contains( expected) ,
1452+ "the override names every artifact, and `{expected}` is missing from: {code}" ,
1453+ ) ;
1454+ }
1455+ }
1456+
1457+ /// An operation with no `operationId` takes its name from the method and the path,
1458+ /// so two such paths can collide as well. The remedy differs, because there is no
1459+ /// `operationId` to change.
1460+ #[ test]
1461+ fn colliding_synthesised_operation_names_fail ( ) {
1462+ let fixture = tests_dir ( )
1463+ . join ( "fixtures" )
1464+ . join ( "operation_name_collision_synthesised.yaml" ) ;
1465+ let err = oapi_codegen:: generate ( & fixture, & server_config ( ) )
1466+ . expect_err ( "two synthesised names that collide must stop generation" ) ;
1467+ let oapi_codegen:: Error :: OperationNameCollision { ident, hint, .. } = & err else {
1468+ panic ! ( "expected a single OperationNameCollision, got: {err:?}" ) ;
1469+ } ;
1470+ assert_eq ! ( ident, "get_widgets_list" ) ;
1471+ assert ! (
1472+ hint. contains( "declares no `operationId`" ) ,
1473+ "the remedy must address the missing `operationId`, got: {hint}" ,
1474+ ) ;
1475+ }
1476+
1477+ /// Filtering runs before lowering, so a collision that a filter removes must not
1478+ /// stop generation. This is the trap the schema-name check fell into: reporting a
1479+ /// collision between an item the file holds and one that it never emits.
1480+ #[ test]
1481+ fn operation_collision_a_filter_removes_still_generates ( ) {
1482+ let fixture = tests_dir ( )
1483+ . join ( "fixtures" )
1484+ . join ( "operation_name_collision_filtered.yaml" ) ;
1485+ let mut config = server_config ( ) ;
1486+ config
1487+ . output_options
1488+ . exclude_operation_ids
1489+ . push ( "listWidgets" . to_owned ( ) ) ;
1490+ let code = oapi_codegen:: generate ( & fixture, & config)
1491+ . expect ( "only one operation survives the filter, so no collision remains" ) ;
1492+ assert ! (
1493+ code. contains( "fn list_widgets" ) ,
1494+ "the operation that survives is emitted: {code}" ,
1495+ ) ;
1496+ assert ! (
1497+ !code. contains( "/gadgets" ) ,
1498+ "the operation the filter removes is not emitted: {code}" ,
1499+ ) ;
1500+ }
1501+
13801502/// A parameter declared `in: path` with no matching `{placeholder}` in the path
13811503/// template must fail generation with a guided error rather than silently drop
13821504/// the parameter from the generated signature.
0 commit comments