diff --git a/examples/overlay/example.yaml b/examples/overlay/example.yaml index 14300c9..a121d37 100644 --- a/examples/overlay/example.yaml +++ b/examples/overlay/example.yaml @@ -8,7 +8,7 @@ # -sources specs/sources.yaml \ # -overlay internal/overlay # -# The overlay layer rewrites help text and aliases for commands synthesized +# The overlay layer rewrites command names, help text, and aliases for commands synthesized # from the upstream spec. The command's Use string matches CommandSpec.Use # emitted by codegen (typically the lowercased operationId / rpc name). # Overrides are baked into the generated CommandSpec at codegen time; the @@ -27,6 +27,7 @@ commands: {} # pageSize: "20" # commands: # create-user: +# use: create # aliases: [adduser] # short: "Create a user in the IAM service" # long: | diff --git a/internal/codegen/render/render.go b/internal/codegen/render/render.go index 87c1b5b..cd180ec 100644 --- a/internal/codegen/render/render.go +++ b/internal/codegen/render/render.go @@ -53,6 +53,9 @@ func RenderModule(name, cliName string, specs []runtime.CommandSpec, overrides m } func ResolveFlatCommandPath(policy string, moduleCount int, specs []runtime.CommandSpec) (bool, error) { + if err := validateCommandPaths(specs); err != nil { + return false, err + } if policy == "" { policy = config.CommandPathAuto } @@ -111,6 +114,41 @@ func flatPathConflict(specs []runtime.CommandSpec) (string, bool) { return "", false } +func validateCommandPaths(specs []runtime.CommandSpec) error { + seen := map[string]runtime.CommandSpec{} + for _, spec := range specs { + group := rootCommandName(spec.Group) + use := commandUseName(spec.Use) + if group == "" || use == "" { + return fmt.Errorf("command %q has empty generated path", commandIdentity(spec)) + } + cmdPath := group + " " + use + if prev, ok := seen[cmdPath]; ok { + return fmt.Errorf("command path %q conflicts between %q and %q", cmdPath, commandIdentity(prev), commandIdentity(spec)) + } + seen[cmdPath] = spec + } + return nil +} + +func commandUseName(use string) string { + fields := strings.Fields(use) + if len(fields) == 0 { + return "" + } + return fields[0] +} + +func commandIdentity(spec runtime.CommandSpec) string { + if spec.OperationID != "" { + return spec.OperationID + } + if spec.Group != "" || spec.Use != "" { + return strings.TrimSpace(spec.Group + " " + spec.Use) + } + return spec.PathTpl +} + func rootCommandName(use string) string { fields := strings.Fields(strings.ToLower(use)) if len(fields) == 0 { @@ -192,6 +230,9 @@ func matchesAny(patterns []string, value string) bool { } func applyCommandOverride(spec *runtime.CommandSpec, override overlay.Override) { + if override.Use != "" { + spec.Use = override.Use + } if override.Short != "" { spec.Short = override.Short } diff --git a/internal/codegen/render/render_test.go b/internal/codegen/render/render_test.go index 46a5749..089ab7e 100644 --- a/internal/codegen/render/render_test.go +++ b/internal/codegen/render/render_test.go @@ -250,6 +250,24 @@ func TestMergeOverlay_ParamRequiredOverride(t *testing.T) { } } +func TestMergeOverlay_UseRename(t *testing.T) { + specs := []runtime.CommandSpec{{ + Group: "Repos", + Use: "create-repo", + Aliases: []string{"new-repo"}, + }} + merged := MergeOverlay(specs, map[string]overlay.Override{ + "create-repo": {Use: "create", Aliases: []string{"new"}}, + }) + + if got := merged[0].Use; got != "create" { + t.Fatalf("Use = %q, want create", got) + } + if !reflect.DeepEqual(merged[0].Aliases, []string{"new-repo", "new"}) { + t.Fatalf("aliases = %#v", merged[0].Aliases) + } +} + func TestMergeOverlayModule_BulkPaginationDefaults(t *testing.T) { specs := []runtime.CommandSpec{ { @@ -488,6 +506,17 @@ func TestResolveFlatCommandPath(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "conflicts") { t.Fatalf("expected duplicate group flat conflict error, got %v", err) } + + renamed := MergeOverlay([]runtime.CommandSpec{ + {Group: "Repos", Use: "create-repo", OperationID: "Repos_CreateRepo"}, + {Group: "Repos", Use: "create", OperationID: "Repos_Create"}, + }, map[string]overlay.Override{ + "create-repo": {Use: "create"}, + }) + _, err = ResolveFlatCommandPath("namespaced", 1, renamed) + if err == nil || !strings.Contains(err.Error(), `command path "repos create" conflicts`) { + t.Fatalf("expected renamed command conflict error, got %v", err) + } } func TestRewriteCommandExamples_NormalizesMultiWordGroupPaths(t *testing.T) { diff --git a/internal/overlay/overlay.go b/internal/overlay/overlay.go index ea75c25..03cec43 100644 --- a/internal/overlay/overlay.go +++ b/internal/overlay/overlay.go @@ -10,6 +10,7 @@ import ( ) type Override struct { + Use string `yaml:"use"` Aliases []string `yaml:"aliases"` Short string `yaml:"short"` Long string `yaml:"long"` diff --git a/internal/overlay/overlay_test.go b/internal/overlay/overlay_test.go index b067163..c2e6789 100644 --- a/internal/overlay/overlay_test.go +++ b/internal/overlay/overlay_test.go @@ -30,6 +30,7 @@ func TestLoadDir_ParsesMultipleModules(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "iam.yaml"), `commands: create-user: + use: create aliases: [adduser, new-user] short: "Create a user" long: "Long description for create-user." @@ -49,6 +50,9 @@ func TestLoadDir_ParsesMultipleModules(t *testing.T) { t.Fatalf("want 2 modules, got %d: %v", len(got), got) } u := got["iam"].Commands["create-user"] + if u.Use != "create" { + t.Errorf("iam create-user use: %q", u.Use) + } if u.Short != "Create a user" || u.Long == "" || u.Example == "" { t.Errorf("iam create-user override incomplete: %+v", u) }