diff --git a/.changesets/1790909966-deaee7f1.yaml b/.changesets/1790909966-deaee7f1.yaml new file mode 100644 index 00000000..9571de52 --- /dev/null +++ b/.changesets/1790909966-deaee7f1.yaml @@ -0,0 +1,10 @@ +id: 1790909966-deaee7f1 +features: + - core +targets: + - cli +type: feat +bump: patch +description: derive offline catalog defaults from command presets and support validated enum groups +author: TristanSpeakEasy +date: "2026-10-02" diff --git a/internal/ast/typedef.go b/internal/ast/typedef.go index 23038353..9b56440c 100644 --- a/internal/ast/typedef.go +++ b/internal/ast/typedef.go @@ -141,6 +141,7 @@ type Enum struct { Open bool `yaml:",omitempty"` Format string `yaml:",omitempty"` // Whether the enum is templated as a native enum or union of literals. If empty use language default Descriptions map[string]string `yaml:",omitempty"` + Groups map[string]string `yaml:",omitempty"` } // Clone creates a deep copy of the Enum @@ -151,6 +152,7 @@ func (e *Enum) Clone() *Enum { return &Enum{ Descriptions: maps.Clone(e.Descriptions), + Groups: maps.Clone(e.Groups), Format: e.Format, Names: slices.Clone(e.Names), Open: e.Open, @@ -1341,6 +1343,9 @@ func (t *TypeDef) IsEqual(other *TypeDef, opts ...IsEqualOpt) error { if !maps.Equal(t.Enum.Descriptions, other.Enum.Descriptions) { return ErrEnumMismatch.Wrap(fmt.Errorf("expected descriptions %v in %s, got %v in %s", t.Enum.Descriptions, t.getNameOrType(), other.Enum.Descriptions, other.getNameOrType())) } + if !maps.Equal(t.Enum.Groups, other.Enum.Groups) { + return ErrEnumMismatch.Wrap(fmt.Errorf("expected groups %v in %s, got %v in %s", t.Enum.Groups, t.getNameOrType(), other.Enum.Groups, other.getNameOrType())) + } } case DataTypeUnion: if t.Discriminator == nil && other.Discriminator != nil || t.Discriminator != nil && other.Discriminator == nil { @@ -1858,6 +1863,9 @@ func (t *TypeDef) JSON() map[string]any { if len(t.Enum.Descriptions) > 0 { enumRet["descriptions"] = t.Enum.Descriptions } + if len(t.Enum.Groups) > 0 { + enumRet["groups"] = t.Enum.Groups + } ret["enum"] = enumRet } diff --git a/internal/ast/typedef_test.go b/internal/ast/typedef_test.go index 180ea18c..aefbbd81 100644 --- a/internal/ast/typedef_test.go +++ b/internal/ast/typedef_test.go @@ -9,6 +9,15 @@ import ( "gopkg.in/yaml.v3" ) +func TestTypeDef_IsEqualEnumGroups(t *testing.T) { + original := &TypeDef{Type: DataTypeEnum, Enum: &Enum{Values: []string{"alpha"}, Groups: map[string]string{"alpha": "Primary"}}} + cloned := original.Clone() + require.NoError(t, original.IsEqual(cloned)) + cloned.Enum.Groups["alpha"] = "Secondary" + require.Equal(t, "Primary", original.Enum.Groups["alpha"]) + require.Error(t, original.IsEqual(cloned)) +} + func TestDiscriminatorMappings_Clone(t *testing.T) { t.Parallel() diff --git a/internal/extensions/cli_catalog_defaults.go b/internal/extensions/cli_catalog_defaults.go new file mode 100644 index 00000000..1785b9f7 --- /dev/null +++ b/internal/extensions/cli_catalog_defaults.go @@ -0,0 +1,113 @@ +package extensions + +import ( + "fmt" + "reflect" + "slices" + "strings" +) + +func (d *cliManifestDecoder) catalogDefaultsForPresets(presets []CLICommandPreset, variant *cliResolvedSchema) ([]CLICatalogDefault, error) { + var defaults []CLICatalogDefault + for _, preset := range presets { + schema, found := variant.properties[cliPointerPropertyName(preset.Bind.Pointer)] + if !found { + continue + } + facts, err := d.propertyFacts(schema, nil) + if err != nil { + return nil, err + } + values, err := d.catalogDefaultsForValue(preset.Value, facts) + if err != nil { + return nil, err + } + for _, value := range values { + if !slices.Contains(defaults, value) { + defaults = append(defaults, value) + } + } + } + return defaults, nil +} + +func (d *cliManifestDecoder) catalogDefaultsForValue(value any, facts *cliPropertyFacts) ([]CLICatalogDefault, error) { + if len(facts.unionArms) > 0 { + var defaults []CLICatalogDefault + matched := false + for _, arm := range facts.unionArms { + armFacts, err := d.propertyFacts(arm, nil) + if err != nil { + return nil, err + } + if d.checkPresetValueStrict("", "", value, armFacts) != nil { + continue + } + candidate, err := d.catalogDefaultsForValue(value, armFacts) + if err != nil { + return nil, err + } + if len(candidate) == 0 { + continue + } + if matched && !reflect.DeepEqual(defaults, candidate) { + return nil, nil + } + defaults = candidate + matched = true + } + return defaults, nil + } + + if items, ok := value.([]any); ok && facts.items != nil { + var defaults []CLICatalogDefault + for _, item := range items { + values, err := d.catalogDefaultsForValue(item, facts.items) + if err != nil { + return nil, err + } + for _, value := range values { + if !slices.Contains(defaults, value) { + defaults = append(defaults, value) + } + } + } + return defaults, nil + } + + for _, member := range facts.enum { + if member == nil || !cliValuesEqual(value, member) { + continue + } + var defaults []CLICatalogDefault + for _, command := range facts.catalogCommands { + defaults = append(defaults, CLICatalogDefault{CatalogCommand: command, Value: fmt.Sprint(member)}) + } + return defaults, nil + } + return nil, nil +} + +func cliLabelCatalogDefaults(cmd *CLICommand, routes [][]CLICatalogDefault) []CLICatalogDefault { + var defaults []CLICatalogDefault + command := strings.Join(cmd.Path, " ") + for i, values := range routes { + for _, value := range values { + shared := true + for _, other := range routes { + if !slices.Contains(other, value) { + shared = false + break + } + } + value.Label = command + if !shared { + value.Label += " --" + cmd.Source.Routes[i].Selector + } + if !slices.Contains(defaults, value) { + defaults = append(defaults, value) + } + } + } + return defaults +} diff --git a/internal/extensions/cli_catalog_defaults_test.go b/internal/extensions/cli_catalog_defaults_test.go new file mode 100644 index 00000000..b39045c6 --- /dev/null +++ b/internal/extensions/cli_catalog_defaults_test.go @@ -0,0 +1,120 @@ +package extensions + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const cliCatalogPresetSpec = `openapi: 3.1.0 +info: {title: Catalog preset test, version: 1.0.0} +paths: + /widgets: + post: + operationId: createWidget + requestBody: + content: + application/json: + schema: + type: object + properties: + choice: {$ref: '#/components/schemas/ChoiceAlias'} + choices: + type: array + items: {$ref: '#/components/schemas/Choices'} + unrelated: {$ref: '#/components/schemas/UnrelatedChoices'} + responses: + '200': {description: OK} +components: + schemas: + ChoiceAlias: + allOf: + - $ref: '#/components/schemas/Choices' + Choices: + type: string + enum: [alpha, beta] + x-speakeasy-unknown-values: allow + x-speakeasy-cli-catalog: {command: choices} + UnrelatedChoices: + type: string + enum: [alpha, beta] + x-speakeasy-cli-catalog: {command: unrelated-choices} +` + +func TestCLICatalogDefaults_LinkedPresets(t *testing.T) { + for _, tt := range []struct { + name string + preset string + want []CLICatalogDefault + }{ + {name: "reference and composition", preset: "{$.choice: alpha}", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha", Label: "create"}}}, + {name: "array", preset: "{$.choices: [beta, alpha, beta]}", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "beta", Label: "create"}, {CatalogCommand: "choices", Value: "alpha", Label: "create"}}}, + {name: "promoted array", preset: "{$.choices: beta}", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "beta", Label: "create"}}}, + {name: "unknown open enum value", preset: "{$.choice: future}"}, + {name: "separate same-valued enum", preset: "{$.unrelated: alpha}", want: []CLICatalogDefault{{CatalogCommand: "unrelated-choices", Value: "alpha", Label: "create"}}}, + } { + t.Run(tt.name, func(t *testing.T) { + manifest, _, err := decodeCLIWithSpec(t, cliCatalogPresetSpec, "version: 1\ncommands:\n create:\n op: createWidget\n preset: "+tt.preset+"\n") + require.NoError(t, err) + require.Len(t, manifest.Commands, 1) + assert.Equal(t, tt.want, manifest.Commands[0].CatalogDefaults) + }) + } +} + +func TestCLICatalogDefaults_EffectiveRoutePresets(t *testing.T) { + spec := strings.ReplaceAll(cliRouteDispatchSpec, " background:\n", " choice: {$ref: '#/components/schemas/Choices'}\n background:\n") + spec += ` Choices: + type: string + enum: [alpha, beta] + x-speakeasy-cli-catalog: {command: choices} +` + for _, tt := range []struct { + name string + routePreset string + want []CLICatalogDefault + }{ + {name: "shared", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha", Label: "jobs run"}}}, + {name: "route override", routePreset: " preset: {$.choice: beta}\n", want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha", Label: "jobs run --engine"}, {CatalogCommand: "choices", Value: "beta", Label: "jobs run --pipeline"}}}, + } { + t.Run(tt.name, func(t *testing.T) { + manifestYAML := strings.Replace(cliRouteDispatchManifest, " op: createJob#PipelineJobParams\n", " op: createJob#PipelineJobParams\n"+tt.routePreset, 1) + manifestYAML += " preset: {$.choice: alpha}\n" + manifest, _, err := decodeCLIWithSpec(t, spec, manifestYAML) + require.NoError(t, err) + assert.Equal(t, tt.want, manifest.Commands[0].CatalogDefaults) + }) + } +} + +func TestCLICatalogDefaults_PartialRouteCoverage(t *testing.T) { + spec := strings.Replace(cliRouteDispatchSpec, " description: Engine selection.\n", " description: Engine selection.\n x-speakeasy-cli-catalog: {command: engines}\n", 1) + manifestYAML := strings.Replace(cliRouteDispatchManifest, " selector: engine\n", " selector: engine\n preset: {$.engine: text-2}\n", 1) + manifest, _, err := decodeCLIWithSpec(t, spec, manifestYAML) + require.NoError(t, err) + assert.Equal(t, []CLICatalogDefault{{CatalogCommand: "engines", Value: "text-2", Label: "jobs run --engine"}}, manifest.Commands[0].CatalogDefaults) +} + +func TestCLICatalogDefaults_UnionAssociation(t *testing.T) { + d := &cliManifestDecoder{} + catalogArm := map[string]any{"type": "string", "enum": []any{"alpha"}, "x-speakeasy-cli-catalog": map[string]any{"command": "choices"}} + for _, tt := range []struct { + name string + other map[string]any + want []CLICatalogDefault + }{ + {name: "unique matching arm", other: map[string]any{"type": "string", "enum": []any{"beta"}}, want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha"}}}, + {name: "plain string arm", other: map[string]any{"type": "string"}, want: []CLICatalogDefault{{CatalogCommand: "choices", Value: "alpha"}}}, + {name: "ambiguous matching arms", other: map[string]any{"type": "string", "enum": []any{"alpha"}, "x-speakeasy-cli-catalog": map[string]any{"command": "other-choices"}}}, + } { + t.Run(tt.name, func(t *testing.T) { + facts, err := d.propertyFacts(map[string]any{"oneOf": []any{catalogArm, tt.other}}, nil) + require.NoError(t, err) + defaults, err := d.catalogDefaultsForValue("alpha", facts) + require.NoError(t, err) + assert.Equal(t, tt.want, defaults) + }) + } +} diff --git a/internal/extensions/cli_commands.go b/internal/extensions/cli_commands.go index e09aa8ae..3a17d133 100644 --- a/internal/extensions/cli_commands.go +++ b/internal/extensions/cli_commands.go @@ -309,24 +309,31 @@ type CLICommandHelp struct { Escalate string `json:"escalate,omitempty" yaml:"escalate,omitempty"` } +type CLICatalogDefault struct { + CatalogCommand string `json:"catalogCommand" yaml:"catalogCommand"` + Value string `json:"value" yaml:"value"` + Label string `json:"label" yaml:"label"` +} + // CLICommand is one declared intent command. type CLICommand struct { - ID string `json:"id" yaml:"id"` - Path []string `json:"path" yaml:"path"` - Category string `json:"category" yaml:"category"` - Summary string `json:"summary" yaml:"summary"` - Tagline string `json:"tagline" yaml:"tagline"` - Description string `json:"description" yaml:"description"` - Source CLICommandSource `json:"source" yaml:"source"` - Args []CLICommandInput `json:"args" yaml:"args"` - Flags []CLICommandInput `json:"flags" yaml:"flags"` - Presets []CLICommandPreset `json:"presets" yaml:"presets"` - Async *CLICommandAsync `json:"async,omitempty" yaml:"async,omitempty"` - Output *CLICommandOutput `json:"output" yaml:"output"` - Examples []CLICommandExample `json:"examples" yaml:"examples"` - Help *CLICommandHelp `json:"help,omitempty" yaml:"help,omitempty"` - Override bool `json:"override,omitempty" yaml:"override,omitempty"` - DispatchKeys []CLICommandDispatchKey `json:"dispatchKeys,omitempty" yaml:"dispatchKeys,omitempty"` + ID string `json:"id" yaml:"id"` + Path []string `json:"path" yaml:"path"` + Category string `json:"category" yaml:"category"` + Summary string `json:"summary" yaml:"summary"` + Tagline string `json:"tagline" yaml:"tagline"` + Description string `json:"description" yaml:"description"` + Source CLICommandSource `json:"source" yaml:"source"` + Args []CLICommandInput `json:"args" yaml:"args"` + Flags []CLICommandInput `json:"flags" yaml:"flags"` + Presets []CLICommandPreset `json:"presets" yaml:"presets"` + CatalogDefaults []CLICatalogDefault `json:"catalogDefaults,omitempty" yaml:"catalogDefaults,omitempty"` + Async *CLICommandAsync `json:"async,omitempty" yaml:"async,omitempty"` + Output *CLICommandOutput `json:"output" yaml:"output"` + Examples []CLICommandExample `json:"examples" yaml:"examples"` + Help *CLICommandHelp `json:"help,omitempty" yaml:"help,omitempty"` + Override bool `json:"override,omitempty" yaml:"override,omitempty"` + DispatchKeys []CLICommandDispatchKey `json:"dispatchKeys,omitempty" yaml:"dispatchKeys,omitempty"` // Hints maps an error reason to agent-mode hint lines that are merged // into the reason-first error envelope. CLI_* reasons are the closed // namespace the generated runtime itself produces; external (server) diff --git a/internal/extensions/cli_commands_link.go b/internal/extensions/cli_commands_link.go index ccc38e5a..44ec0801 100644 --- a/internal/extensions/cli_commands_link.go +++ b/internal/extensions/cli_commands_link.go @@ -93,19 +93,20 @@ type cliResolvedSchema struct { // cliPropertyFacts is the classification of a single property schema used for // inference and preset validation. type cliPropertyFacts struct { - kinds []string // resolved scalar kind(s): string,int,float,bool — or array,object - enum []any - openEnum bool - defaultVal any - hasDefault bool - constVal any - hasConst bool - description string - readOnly bool - arrayOfStr bool // exactly array (for bounded promotion) - items *cliPropertyFacts - itemsErr error - unionArms []any // raw member schemas when the property is a union + kinds []string // resolved scalar kind(s): string,int,float,bool — or array,object + enum []any + catalogCommands []string + openEnum bool + defaultVal any + hasDefault bool + constVal any + hasConst bool + description string + readOnly bool + arrayOfStr bool // exactly array (for bounded promotion) + items *cliPropertyFacts + itemsErr error + unionArms []any // raw member schemas when the property is a union } func (d *cliManifestDecoder) linkManifest(manifest *CLICommandManifest) error { @@ -484,6 +485,11 @@ func (d *cliManifestDecoder) linkSingleRouteCommand(cmd *CLICommand) error { } d.checkSatisfiability(key, cmd, variant, presetPointers) + defaults, err := d.catalogDefaultsForPresets(cmd.Presets, variant) + if err != nil { + return err + } + cmd.CatalogDefaults = cliLabelCatalogDefaults(cmd, [][]CLICatalogDefault{defaults}) return nil } @@ -687,9 +693,16 @@ func (d *cliManifestDecoder) linkDispatchCommand(cmd *CLICommand) error { return err } cmd.DispatchKeys = cliBuildDispatchKeys(cmd.Source.Routes, members) - for _, link := range links { + catalogDefaults := make([][]CLICatalogDefault, len(links)) + for i, link := range links { d.checkDispatchSatisfiability(key, cmd, link) + defaults, err := d.catalogDefaultsForPresets(link.route.Presets, link.variant) + if err != nil { + return err + } + catalogDefaults[i] = defaults } + cmd.CatalogDefaults = cliLabelCatalogDefaults(cmd, catalogDefaults) return nil } @@ -2733,6 +2746,12 @@ func cliUnionMembers(schema map[string]any) []any { // when either layer declares it. func mergePropertyFacts(ref, sibling *cliPropertyFacts) (*cliPropertyFacts, error) { out := *ref + out.catalogCommands = slices.Clone(ref.catalogCommands) + for _, command := range sibling.catalogCommands { + if !slices.Contains(out.catalogCommands, command) { + out.catalogCommands = append(out.catalogCommands, command) + } + } if sibling.kinds != nil { switch { case out.kinds == nil: @@ -2894,6 +2913,11 @@ func (d *cliManifestDecoder) propertyFacts(schema any, seen []string) (*cliPrope } facts := &cliPropertyFacts{} + if catalog, ok := schemaMap["x-speakeasy-cli-catalog"].(map[string]any); ok { + if command, ok := catalog["command"].(string); ok && command != "" { + facts.catalogCommands = []string{command} + } + } if desc, ok := schemaMap["description"].(string); ok { facts.description = desc } diff --git a/internal/extensions/enum_groups_test.go b/internal/extensions/enum_groups_test.go new file mode 100644 index 00000000..5fd4e1c6 --- /dev/null +++ b/internal/extensions/enum_groups_test.go @@ -0,0 +1,75 @@ +package extensions + +import ( + "testing" + + "github.com/speakeasy-api/openapi-generation/v2/internal/types" + oasextensions "github.com/speakeasy-api/openapi/extensions" + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestGetEnumGroups(t *testing.T) { + var enumNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("[red, null, blue, blue]"), &enumNode)) + schema := &oas3.Schema{ + Enum: enumNode.Content[0].Content, + Extensions: oasextensions.New(), + } + var groupNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("[ ' Primary ', '', Secondary, 'Ignored duplicate' ]"), &groupNode)) + schema.Extensions.Set(ExtEnumGroups.Name(), groupNode.Content[0]) + + groups, groupMap, err := New(types.NewTargetFromTemplate("go")).GetEnumGroups(schema) + require.NoError(t, err) + require.Equal(t, []string{"Primary", "", "Secondary", "Ignored duplicate"}, groups) + require.Nil(t, groupMap) + + var mapNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("red: ' First '\nblue: ''"), &mapNode)) + schema.Extensions.Set(ExtEnumGroups.Name(), mapNode.Content[0]) + groups, groupMap, err = New(types.NewTargetFromTemplate("go")).GetEnumGroups(schema) + require.NoError(t, err) + require.Nil(t, groups) + require.Equal(t, map[string]string{"red": "First"}, groupMap) +} + +func TestGetEnumGroupsScalarKeys(t *testing.T) { + var enumNode, groupNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("[1, 2]"), &enumNode)) + require.NoError(t, yaml.Unmarshal([]byte("1: ' Primary '\n'2': Secondary"), &groupNode)) + schema := &oas3.Schema{Enum: enumNode.Content[0].Content, Extensions: oasextensions.New()} + schema.Extensions.Set(ExtEnumGroups.Name(), groupNode.Content[0]) + groups, groupMap, err := New(types.NewTargetFromTemplate("go")).GetEnumGroups(schema) + require.NoError(t, err) + require.Nil(t, groups) + require.Equal(t, map[string]string{"1": "Primary", "2": "Secondary"}, groupMap) +} + +func TestGetEnumGroupsRejectsInvalidValues(t *testing.T) { + var enumNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("[red, blue]"), &enumNode)) + cases := []struct { + name string + raw string + }{ + {name: "non-string list member", raw: "[Primary, 1]"}, + {name: "wrong list length", raw: "[Primary]"}, + {name: "non-string map value", raw: "red: 1"}, + {name: "unknown map key", raw: "green: Other"}, + {name: "duplicate map key", raw: "red: Primary\nred: Secondary"}, + {name: "scalar", raw: "Primary"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var groupNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(tc.raw), &groupNode)) + extensions := oasextensions.New() + extensions.Set(ExtEnumGroups.Name(), groupNode.Content[0]) + schema := &oas3.Schema{Enum: enumNode.Content[0].Content, Extensions: extensions} + _, _, err := New(types.NewTargetFromTemplate("go")).GetEnumGroups(schema) + require.Error(t, err) + }) + } +} diff --git a/internal/extensions/enums.go b/internal/extensions/enums.go index 37d67492..11301fb7 100644 --- a/internal/extensions/enums.go +++ b/internal/extensions/enums.go @@ -2,9 +2,11 @@ package extensions import ( "fmt" + "strings" "github.com/speakeasy-api/openapi-generation/v2/pkg/errors" "github.com/speakeasy-api/openapi/jsonschema/oas3" + "gopkg.in/yaml.v3" ) func (e *Extensions) GetEnumNames(schema *oas3.Schema) ([]string, map[any]string, error) { @@ -55,6 +57,64 @@ func (e *Extensions) GetEnumDescriptions(schema *oas3.Schema) ([]string, map[any return nil, descriptionMap, nil } +func (e *Extensions) GetEnumGroups(schema *oas3.Schema) ([]string, map[string]string, error) { + if schema.GetExtensions().Len() == 0 { + return nil, nil, nil + } + + node, ok := e.findExtension(schema.GetExtensions(), ExtEnumGroups) + if !ok { + return nil, nil, nil + } + + values := schema.GetEnum() + switch node.Kind { + case yaml.SequenceNode: + if len(node.Content) != len(values) { + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` array must be the same length as enum values array", node, nil) + } + groups := make([]string, len(node.Content)) + for i, item := range node.Content { + if item == nil || item.Kind != yaml.ScalarNode || item.Tag != "!!str" { + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` entries must be strings", item, nil) + } + groups[i] = strings.TrimSpace(item.Value) + } + return groups, nil, nil + case yaml.MappingNode: + if len(node.Content)%2 != 0 { + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` must be a map of enum values to group titles", node, nil) + } + knownValues := make(map[string]struct{}, len(values)) + for _, value := range values { + if value != nil && value.Kind == yaml.ScalarNode && value.Tag != "!!null" { + knownValues[value.Value] = struct{}{} + } + } + groups := make(map[string]string, len(node.Content)/2) + seen := make(map[string]struct{}, len(node.Content)/2) + for i := 0; i < len(node.Content); i += 2 { + key, title := node.Content[i], node.Content[i+1] + if key == nil || key.Kind != yaml.ScalarNode || key.Tag == "!!null" || title == nil || title.Kind != yaml.ScalarNode || title.Tag != "!!str" { + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` keys must be enum values and titles must be strings", node, nil) + } + if _, ok := knownValues[key.Value]; !ok { + return nil, nil, errors.NewValidationError(fmt.Sprintf("`x-speakeasy-enum-groups` map contains key `%s` that does not exist in enum values", key.Value), key, nil) + } + if _, exists := seen[key.Value]; exists { + return nil, nil, errors.NewValidationError(fmt.Sprintf("`x-speakeasy-enum-groups` map contains duplicate key `%s`", key.Value), key, nil) + } + seen[key.Value] = struct{}{} + if trimmed := strings.TrimSpace(title.Value); trimmed != "" { + groups[key.Value] = trimmed + } + } + return nil, groups, nil + default: + return nil, nil, errors.NewValidationError("`x-speakeasy-enum-groups` must be either an array or a map", node, nil) + } +} + func (e *Extensions) IsOpenEnum(schema *oas3.Schema) (bool, error) { if schema.GetExtensions().Len() == 0 { return false, nil diff --git a/internal/extensions/extensions.go b/internal/extensions/extensions.go index 950aa404..0fa14909 100644 --- a/internal/extensions/extensions.go +++ b/internal/extensions/extensions.go @@ -94,6 +94,7 @@ const ( ExtGoOptionalMethodArguments ExtCLICommands ExtCLIErrors + ExtEnumGroups ) var extensionNames = map[Extension]string{ @@ -162,6 +163,7 @@ var extensionNames = map[Extension]string{ ExtPublicExports: "x-speakeasy-exports", ExtCLICommands: "x-speakeasy-cli-commands", ExtCLIErrors: "x-speakeasy-cli-errors", + ExtEnumGroups: "x-speakeasy-enum-groups", ExtGoOptionalMethodArguments: "x-speakeasy-go-optional-method-arguments", } diff --git a/internal/schemas/schemas.go b/internal/schemas/schemas.go index b837bedf..33152e73 100644 --- a/internal/schemas/schemas.go +++ b/internal/schemas/schemas.go @@ -1422,6 +1422,22 @@ func (s *Schemas) handleEnum(ctx context.Context, params Params, nullable bool) return nil, err } + groupList, groupMap, err := s.Subsystem.Extensions.GetEnumGroups(schema) + if err != nil { + return nil, err + } + enumGroups := groupMap + if len(groupList) > 0 { + enumGroups = make(map[string]string) + for i, group := range groupList { + if i < len(schema.Enum) && schema.Enum[i] != nil && schema.Enum[i].Tag != "!!null" && group != "" { + if _, exists := enumGroups[schema.Enum[i].Value]; !exists { + enumGroups[schema.Enum[i].Value] = group + } + } + } + } + if len(schema.GetEnum()) == 1 && schema.GetEnum()[0].Tag == "!!null" { schema.Nullable = pointer.From(true) schema.Const = schema.GetEnum()[0] @@ -1551,6 +1567,7 @@ func (s *Schemas) handleEnum(ctx context.Context, params Params, nullable bool) Values: enumValues, Names: names, Descriptions: enumDescriptions, + Groups: enumGroups, Open: isOpen, Format: enumFormat, }, diff --git a/internal/validation/validateenums.go b/internal/validation/validateenums.go index 01c2d2e0..1ccedb02 100644 --- a/internal/validation/validateenums.go +++ b/internal/validation/validateenums.go @@ -7,6 +7,8 @@ import ( "slices" "strconv" + "github.com/speakeasy-api/openapi-generation/v2/internal/extensions" + "github.com/speakeasy-api/openapi-generation/v2/internal/types" "github.com/speakeasy-api/openapi-generation/v2/internal/validation/sanitization" "github.com/speakeasy-api/openapi/linter" "github.com/speakeasy-api/openapi/openapi" @@ -83,6 +85,16 @@ func (r *ValidateEnums) Run(ctx context.Context, docInfo *linter.DocumentInfo[*o continue // Not an enum } + if _, _, err := extensions.New(types.NewTargetFromTemplate("")).GetEnumGroups(schema); err != nil { + extensionNode, _ := schema.GetExtensions().Get(extensions.ExtEnumGroups.Name()) + validationErrors = append(validationErrors, &validation.Error{ + Rule: r.ID(), + Severity: r.DefaultSeverity(), + Node: extensionNode, + UnderlyingError: err, + }) + } + schemaNode := schemaRef.GetRootNode() if schemaNode == nil { continue diff --git a/internal/validation/validateenums_test.go b/internal/validation/validateenums_test.go index 98f2ac40..3405dd40 100644 --- a/internal/validation/validateenums_test.go +++ b/internal/validation/validateenums_test.go @@ -2,6 +2,7 @@ package validation_test import ( "context" + "strings" "testing" "github.com/speakeasy-api/openapi-generation/v2/internal/types" @@ -788,6 +789,45 @@ paths: } } +func Test_ValidateEnums_EnumGroups(t *testing.T) { + cases := []struct { + name string + groups string + wantText string + }{ + {name: "unknown enum key", groups: " green: Other", wantText: "map contains key `green` that does not exist"}, + {name: "wrong positional length", groups: " - First", wantText: "array must be the same length as enum values array"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, err := validation.NewValidator(&config.Configuration{}, validation.RulesetSpeakeasyRecommended, validation.WithFilteredRules([]string{(&validation.ValidateEnums{}).ID()})) + require.NoError(t, err) + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: {} +components: + schemas: + Status: + type: string + enum: [red, null, blue] + x-speakeasy-enum-groups: +` + tc.groups + result := validateSpec(v, context.Background(), []byte(spec), "", types.NewTargetFromTemplate("go")) + errs := result.GetValidationErrors() + require.NotEmpty(t, errs) + var found bool + for _, validationErr := range errs { + if strings.Contains(validationErr.Error(), tc.wantText) { + found = true + } + } + assert.True(t, found) + }) + } +} + // Test_ValidateEnums_NullableWithCustomNames tests that nullable enums with // x-speakeasy-enums work correctly (null should be skipped in both arrays) func Test_ValidateEnums_NullableWithCustomNames(t *testing.T) { diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 83a791f4..870a85da 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -18,15 +18,19 @@ Generates a fully functional Go CLI from an OpenAPI specification. The generated - [Positional path parameter](#positional-path-parameter) - [Metadata-Driven Request Building](#metadata-driven-request-building) - [Flag Metadata Generation](#flag-metadata-generation) + - [Union Type Handling](#union-type-handling) - [Declarative Intent Commands and Route Dispatch](#declarative-intent-commands-and-route-dispatch) + - [Offline Enum Catalogs](#offline-enum-catalogs) - [Output Formatting](#output-formatting) + - [Interactive Mode](#interactive-mode) - [Agent Mode](#agent-mode) - [Exit Codes and Error Boundaries](#exit-codes-and-error-boundaries) - [Pagination](#pagination) - [Streaming (SSE \& JSONL)](#streaming-sse--jsonl) - [Streamed-event projection (`x-speakeasy-cli-commands` `output.stream.select`)](#streamed-event-projection-x-speakeasy-cli-commands-outputstreamselect) + - [Binary Downloads](#binary-downloads) - [Bytes / Base64 Request Input](#bytes--base64-request-input) - [Retries \& Timeout](#retries--timeout) @@ -576,6 +580,86 @@ JSON-body intent commands, including single-route commands, never register backi `override: true` replaces only the generated registration at the operation's exact canonical command path. The command must target that same operation, cover every component member, and replace a non-promoted leaf. The operation file is still generated so the intent reuses its metadata and run function. Runtime registration, KDL usage, generated README command trees and examples all omit the generated operation entry and insert the intent in manifest order; generated Arazzo tests that still encode the removed flag surface are skipped, while dedicated intent tests cover the replacement. Without `override`, the generated operation remains available as the full-control escape. +### Offline Enum Catalogs + +**Files**: `includes/templating.ts` (`collectCliCatalogs`), `catalog.go.stmpl`, and `main.ts` + +An enum schema annotated with `x-speakeasy-cli-catalog` generates an offline listing command. The catalog extension accepts `command`, `summary`, `description`, and the existing scalar `default`. Command-specific defaults come from actual command presets, not a second catalog configuration. Groups are schema metadata declared with `x-speakeasy-enum-groups`, alongside enum descriptions. + +For example, these commands share one enum, but select different options by default: + +```yaml +openapi: 3.1.0 +info: {title: Widget API, version: 1.0.0} +x-speakeasy-cli-commands: + version: 1 + commands: + create-widget: + op: createWidget + preset: {$.mode: alpha} + inspect-widget: + op: createWidget + preset: {$.mode: alpha} + render-widget: + op: createWidget + preset: {$.mode: gamma} +paths: + /widgets: + post: + operationId: createWidget + requestBody: + content: + application/json: + schema: + type: object + properties: + mode: {$ref: "#/components/schemas/WidgetMode"} + responses: + "200": {description: OK} +components: + schemas: + WidgetMode: + type: string + enum: [alpha, beta, gamma, delta] + x-speakeasy-enum-descriptions: + alpha: First option. + beta: Second option. + gamma: Third option. + delta: Fourth option. + x-speakeasy-enum-groups: + alpha: Primary + beta: Primary + gamma: Secondary + x-speakeasy-cli-catalog: + command: widget-options + summary: List available widget options +``` + +`cli widget-options` displays: + +```text +Primary: + alpha (default: create-widget, inspect-widget) First option. + beta Second option. + +Secondary: + gamma (default: render-widget) Third option. + +Other: + delta Fourth option. +``` + +Each enum value becomes one `CliCatalogValue` row. `CliCatalog.Groups` organises those same rows into human-output sections; `CliCatalog.Values` flattens them in the same order for machine output. Grouping does not create additional enum values or change request values. + +- Go links each effective preset to its resolved enum schema, including references and array-item enums. Route-local presets override command presets. When a command's routes select different defaults, labels include the actual route selector, such as `create --image`; defaults shared by every route use the plain command name. Unknown values accepted by an open enum are not catalog entries, and ambiguous property unions do not assign a preset to unrelated catalogs. +- Only the scalar catalog `default` sets the existing machine-output `default` boolean. It remains independent of schema defaults and command presets. Command defaults produce ` (default: , ...)`; otherwise the scalar catalog default produces ` (default)`. +- `x-speakeasy-enum-groups` accepts a value-to-title map, or a positional string list matching the original enum length, for example `[Primary, Primary, Secondary, ""]`. The list includes slots for null and duplicate enum members; null slots do not create rows and duplicate values use their first non-empty title. Go validates the extension before rendering. Unknown values, duplicate map keys, non-string titles, malformed shapes and wrong-length lists are errors. Titles are trimmed; missing map entries and empty titles are ungrouped. +- Groups appear in first-seen enum order, and values within each group retain enum order. If at least one group exists, remaining values appear in a trailing `Other` section, unless a group is already titled `Other`. In that case, the remaining values join that group in enum order without changing its section position. If all titles are empty or no groups are declared, output stays flat without `group` fields. +- Grouped human output uses `:` headings, two-space indentation and a blank line between groups. Effective command defaults or groups use a label width of `max(42, longest label length + 2)`. Catalogs without either retain the original fixed width of 42, including legacy long-label spacing. +- Machine output remains a flat array with `value`, `description` and `default`. Non-empty command defaults add `default_for`; grouped catalogs add `group`, including the `Other` remainder. + +The nested catalog `defaults` and `groups` keys are not supported. Use command presets and the schema-level enum group extension instead. + ### Output Formatting **Files**: `auxiliary/internal/output/` diff --git a/templates/templates/cli/catalog.go.stmpl b/templates/templates/cli/catalog.go.stmpl index 4cb6ac27..d4e165a8 100644 --- a/templates/templates/cli/catalog.go.stmpl +++ b/templates/templates/cli/catalog.go.stmpl @@ -46,15 +46,28 @@ func new{{.FuncName}}CatalogCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { values := []map[string]interface{}{ {{- range .Values}} - {"value": "{{escapeGoString .Value}}", "description": "{{escapeGoString .Description}}", "default": {{if .IsDefault}}true{{else}}false{{end}}}, + {"value": "{{escapeGoString .Value}}", "description": "{{escapeGoString .Description}}", "default": {{if .IsDefault}}true{{else}}false{{end}}{{if .DefaultFor}}, "default_for": []string{ {{range .DefaultFor}}"{{escapeGoString .}}", {{end}}}{{end}}{{if .Group}}, "group": "{{escapeGoString .Group}}"{{end}}}, {{- end}} } if output.IsMachineMode(cmd) { return output.LocalResult(cmd, values) } out := cmd.OutOrStdout() + {{- $width := .ColWidth}} + {{- if .Groups}} + {{- range $index, $group := .Groups}} + {{- if $index}} + fmt.Fprintln(out) + {{- end}} + fmt.Fprintln(out, "{{escapeGoString .Title}}:") {{- range .Values}} - fmt.Fprintf(out, "%-42s %s\n", "{{escapeGoString .Value}}{{if .IsDefault}} (default){{end}}", "{{escapeGoString .Description}}") + fmt.Fprintf(out, " %-{{$width}}s %s\n", "{{escapeGoString .Label}}", "{{escapeGoString .Description}}") + {{- end}} + {{- end}} + {{- else}} + {{- range .Values}} + fmt.Fprintf(out, "%-{{$width}}s %s\n", "{{escapeGoString .Label}}", "{{escapeGoString .Description}}") + {{- end}} {{- end}} return nil }, diff --git a/templates/templates/cli/includes/templating.ts b/templates/templates/cli/includes/templating.ts index c3775055..09ae38a8 100644 --- a/templates/templates/cli/includes/templating.ts +++ b/templates/templates/cli/includes/templating.ts @@ -622,6 +622,9 @@ interface CliCatalogValue { Value: string; Description: string; IsDefault: boolean; + DefaultFor: string[]; + Group: string; + Label: string; } interface CliCatalog { @@ -630,6 +633,8 @@ interface CliCatalog { Summary: string; Description: string; Values: CliCatalogValue[]; + Groups: { Title: string; Values: CliCatalogValue[] }[]; + ColWidth: number; } /** @@ -647,59 +652,109 @@ function collectCliCatalogs(): CliCatalog[] { const byCommand = new Map<string, string>(); const byFuncName = new Map<string, string>(); let collisionError = ""; - try { - const buckets = context.Global.AST.BucketedTypes; - for (const [, models] of sequencedMapEntries(buckets)) { - for (const [, types] of sequencedMapEntries(models)) { - for (const t of types as TypeDef[]) { - const ext: any = t.Extensions?.All?.["x-speakeasy-cli-catalog"]; - if (!ext || typeof ext !== "object" || !ext.command) continue; - if (t.Type?.toString() !== "enum" || !t.Enum) continue; - if (seen.has(ext.command)) continue; - seen.add(ext.command); - const commandName = sanitizeCLICommand(`${ext.command}`); - const funcName = sanitizeClassName(`${ext.command}`); - const priorCommand = byCommand.get(commandName); - const priorFunc = byFuncName.get(funcName); - if (priorCommand !== undefined && priorCommand !== `${ext.command}`) { - collisionError = `x-speakeasy-cli-catalog: commands "${priorCommand}" and "${ext.command}" normalize to the same CLI command "${commandName}"; rename one`; - break; + const buckets = context.Global.AST.BucketedTypes; + for (const [, models] of sequencedMapEntries(buckets)) { + for (const [, types] of sequencedMapEntries(models)) { + for (const t of types as TypeDef[]) { + const ext: any = t.Extensions?.All?.["x-speakeasy-cli-catalog"]; + if (!ext || typeof ext !== "object" || !ext.command) continue; + if (t.Type?.toString() !== "enum" || !t.Enum) continue; + if (seen.has(ext.command)) continue; + seen.add(ext.command); + const commandName = sanitizeCLICommand(`${ext.command}`); + const funcName = sanitizeClassName(`${ext.command}`); + const priorCommand = byCommand.get(commandName); + const priorFunc = byFuncName.get(funcName); + if (priorCommand !== undefined && priorCommand !== `${ext.command}`) { + collisionError = `x-speakeasy-cli-catalog: commands "${priorCommand}" and "${ext.command}" normalize to the same CLI command "${commandName}"; rename one`; + break; + } + if (priorFunc !== undefined && priorFunc !== `${ext.command}`) { + collisionError = `x-speakeasy-cli-catalog: commands "${priorFunc}" and "${ext.command}" normalize to the same generated identifier "${funcName}"; rename one`; + break; + } + byCommand.set(commandName, `${ext.command}`); + byFuncName.set(funcName, `${ext.command}`); + const defaultValue = ext.default !== undefined ? `${ext.default}` : ""; + if (ext.defaults !== undefined || ext.groups !== undefined) { + throw new Error( + "x-speakeasy-cli-catalog: derive command defaults from command presets and declare groups with x-speakeasy-enum-groups", + ); + } + const defaults = new Map<string, string[]>(); + for (const cmd of context.Global.AST.CLICommands?.Commands || []) { + for (const entry of cmd.CatalogDefaults || []) { + if (entry.CatalogCommand !== ext.command) continue; + const labels = defaults.get(entry.Value) || []; + if (!labels.includes(entry.Label)) labels.push(entry.Label); + defaults.set(entry.Value, labels); + } + } + const values: CliCatalogValue[] = (t.Enum.Values || []).map( + (v: string) => { + const description = t.Enum.Descriptions?.[v]; + const defaultFor = defaults.get(v) || []; + const isDefault = defaultValue !== "" && v === defaultValue; + return { + Value: v, + Description: typeof description === "string" ? description : "", + IsDefault: isDefault, + DefaultFor: defaultFor, + Group: t.Enum.Groups?.[v] || "", + Label: + defaultFor.length > 0 + ? `${v} (default: ${defaultFor.join(", ")})` + : isDefault + ? `${v} (default)` + : v, + }; + }, + ); + if (values.length === 0) continue; + const groups: CliCatalog["Groups"] = []; + const byTitle = new Map<string, CliCatalog["Groups"][number]>(); + for (const value of values) { + if (!value.Group) continue; + let group = byTitle.get(value.Group); + if (!group) { + group = { Title: value.Group, Values: [] }; + byTitle.set(value.Group, group); + groups.push(group); } - if (priorFunc !== undefined && priorFunc !== `${ext.command}`) { - collisionError = `x-speakeasy-cli-catalog: commands "${priorFunc}" and "${ext.command}" normalize to the same generated identifier "${funcName}"; rename one`; - break; + group.Values.push(value); + } + const remaining = values.filter((value) => !value.Group); + if (groups.length > 0 && remaining.length > 0) { + for (const value of remaining) value.Group = "Other"; + const other = byTitle.get("Other"); + if (other) { + other.Values = values.filter((value) => value.Group === "Other"); + } else { + groups.push({ Title: "Other", Values: remaining }); } - byCommand.set(commandName, `${ext.command}`); - byFuncName.set(funcName, `${ext.command}`); - const defaultValue = - ext.default !== undefined ? `${ext.default}` : ""; - const values: CliCatalogValue[] = (t.Enum.Values || []).map( - (v: string) => { - const description = t.Enum.Descriptions?.[v]; - return { - Value: v, - Description: typeof description === "string" ? description : "", - IsDefault: defaultValue !== "" && v === defaultValue, - }; - }, - ); - if (values.length === 0) continue; - catalogs.push({ - Command: sanitizeCLICommand(`${ext.command}`), - FuncName: sanitizeClassName(`${ext.command}`), - Summary: `${ext.summary || `List available ${ext.command}`}`, - Description: `${ext.description || ""}`, - Values: values, - }); } + const hasDefaults = values.some((value) => value.DefaultFor.length > 0); + const hasGroups = groups.length > 0; + catalogs.push({ + Command: sanitizeCLICommand(`${ext.command}`), + FuncName: sanitizeClassName(`${ext.command}`), + Summary: `${ext.summary || `List available ${ext.command}`}`, + Description: `${ext.description || ""}`, + Values: hasGroups ? groups.flatMap((group) => group.Values) : values, + Groups: groups, + ColWidth: + hasDefaults || hasGroups + ? Math.max( + 42, + ...values.map((value) => Array.from(value.Label).length + 2), + ) + : 42, + }); } } - } catch (e) { - // Catalog rendering is best-effort; a malformed extension never breaks generation. } if (collisionError) { - // Identifier collisions would emit uncompilable Go; unlike malformed - // extensions they must fail generation with the collision named. + // Identifier collisions would emit uncompilable Go. throw new Error(collisionError); } return catalogs; diff --git a/templates/templates/cli/tests/primary/catalog_test.go.stmpl b/templates/templates/cli/tests/primary/catalog_test.go.stmpl index 4ff56444..f1bcaf46 100644 --- a/templates/templates/cli/tests/primary/catalog_test.go.stmpl +++ b/templates/templates/cli/tests/primary/catalog_test.go.stmpl @@ -2,41 +2,153 @@ package tests import ( "encoding/json" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +var catalogOutputCases = []struct { + command string + human string + json string +}{ + { + command: "catalog-legacy", + human: fmt.Sprintf("%-42s %s\n%-42s %s\n", + "catalog-value-with-a-name-longer-than-forty-two-characters (default)", "Long option.", + "beta", "Second option."), + json: `[{"default":true,"description":"Long option.","value":"catalog-value-with-a-name-longer-than-forty-two-characters"},{"default":false,"description":"Second option.","value":"beta"}]`, + }, + { + command: "catalog-noop", + human: fmt.Sprintf("%-42s %s\n%-42s %s\n", + "catalog-value-with-a-name-longer-than-forty-two-characters (default)", "Long option.", + "beta", "Second option."), + json: `[{"default":true,"description":"Long option.","value":"catalog-value-with-a-name-longer-than-forty-two-characters"},{"default":false,"description":"Second option.","value":"beta"}]`, + }, + { + command: "catalog-empty", + human: fmt.Sprintf("%-42s %s\n%-42s %s\n", + "alpha (default)", "First option.", "beta", "Second option."), + json: `[ + {"default":true,"description":"First option.","value":"alpha"}, + {"default":false,"description":"Second option.","value":"beta"} + ]`, + }, + { + command: "catalog-defaults", + human: fmt.Sprintf("%-48s %s\n%-48s %s\n%-48s %s\n%-48s %s\n%-48s %s\n", + "alpha (default: create-widget, inspect-widget)", "First option.", + "beta", "Second option.", "gamma (default: choose-gamma)", "Third option.", + "delta", "Fourth option.", "epsilon", "Fifth option."), + json: `[ + {"default":false,"default_for":["create-widget","inspect-widget"],"description":"First option.","value":"alpha"}, + {"default":false,"description":"Second option.","value":"beta"}, + {"default":false,"default_for":["choose-gamma"],"description":"Third option.","value":"gamma"}, + {"default":false,"description":"Fourth option.","value":"delta"}, + {"default":false,"description":"Fifth option.","value":"epsilon"} + ]`, + }, + { + command: "catalog-groups", + human: "Primary \"Widgets\":\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n", + "alpha", "First option.", "beta", "Second option.") + + "\nSecondary:\n" + fmt.Sprintf(" %-42s %s\n", "gamma", "Third option.") + + "\nOther:\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n", + "delta (default)", "Fourth option.", "epsilon", "Fifth option."), + json: `[ + {"default":false,"description":"First option.","group":"Primary \"Widgets\"","value":"alpha"}, + {"default":false,"description":"Second option.","group":"Primary \"Widgets\"","value":"beta"}, + {"default":false,"description":"Third option.","group":"Secondary","value":"gamma"}, + {"default":true,"description":"Fourth option.","group":"Other","value":"delta"}, + {"default":false,"description":"Fifth option.","group":"Other","value":"epsilon"} + ]`, + }, + { + command: "catalog-combined", + human: "Primary \"Widgets\":\n" + fmt.Sprintf(" %-48s %s\n %-48s %s\n", + "alpha (default: create-widget, inspect-widget)", "First option.", "beta", "Second option.") + + "\nSecondary:\n" + fmt.Sprintf(" %-48s %s\n", "gamma (default: choose-gamma)", "Third option.") + + "\nOther:\n" + fmt.Sprintf(" %-48s %s\n %-48s %s\n", + "delta", "Fourth option.", "epsilon", "Fifth option."), + json: `[ + {"default":true,"default_for":["create-widget","inspect-widget"],"description":"First option.","group":"Primary \"Widgets\"","value":"alpha"}, + {"default":false,"description":"Second option.","group":"Primary \"Widgets\"","value":"beta"}, + {"default":false,"default_for":["choose-gamma"],"description":"Third option.","group":"Secondary","value":"gamma"}, + {"default":false,"description":"Fourth option.","group":"Other","value":"delta"}, + {"default":false,"description":"Fifth option.","group":"Other","value":"epsilon"} + ]`, + }, + { + command: "catalog-other", + human: "Other:\n" + fmt.Sprintf(" %-42s %s\n %-42s %s\n %-42s %s\n %-42s %s\n", + "alpha", "First option.", "beta", "Second option.", "gamma", "Third option.", "epsilon", "Fifth option.") + + "\nPrimary:\n" + fmt.Sprintf(" %-42s %s\n", "delta", "Fourth option."), + json: `[ + {"default":false,"description":"First option.","group":"Other","value":"alpha"}, + {"default":false,"description":"Second option.","group":"Other","value":"beta"}, + {"default":false,"description":"Third option.","group":"Other","value":"gamma"}, + {"default":false,"description":"Fifth option.","group":"Other","value":"epsilon"}, + {"default":false,"description":"Fourth option.","group":"Primary","value":"delta"} + ]`, + }, + { + command: "catalog-ungrouped", + human: fmt.Sprintf("%-42s %s\n%-42s %s\n%-42s %s\n", + "alpha (default: choose-gamma)", "First option.", "beta", "Second option.", "gamma", "Third option."), + json: `[ + {"default":false,"default_for":["choose-gamma"],"description":"First option.","value":"alpha"}, + {"default":false,"description":"Second option.","value":"beta"}, + {"default":false,"description":"Third option.","value":"gamma"} + ]`, + }, +} + func TestCatalogListsValues(t *testing.T) { recordTest("catalog-lists-values") h := NewCLITestHarness(t) - err := h.RunRaw([]string{"hygiene-models"}) - require.NoError(t, err) - stdout := h.GetStdout() - assert.Contains(t, stdout, "standard") - assert.Contains(t, stdout, "(default)") - assert.Contains(t, stdout, "fast") + require.NoError(t, h.RunRaw([]string{"hygiene-models"})) + assert.Equal(t, fmt.Sprintf("%-42s %s\n%-42s %s\n", "standard (default)", "", "fast", ""), h.GetStdout()) + + for _, tc := range catalogOutputCases { + t.Run(tc.command, func(t *testing.T) { + h := NewCLITestHarness(t) + require.NoError(t, h.RunRaw([]string{tc.command})) + assert.Equal(t, tc.human, h.GetStdout()) + assert.Empty(t, h.GetStderr()) + }) + } } func TestCatalogStructuredOutput(t *testing.T) { recordTest("catalog-structured-output") - s := newEchoServer() - defer s.Close() - h := NewCLITestHarness(t) - err := h.RunWithServer(s.URL, []string{"hygiene-models"}) - require.NoError(t, err) - var values []struct { - Value string `json:"value"` - Description string `json:"description"` - Default bool `json:"default"` - } - require.NoError(t, json.Unmarshal([]byte(h.GetStdout()), &values)) - require.NotEmpty(t, values) - assert.Equal(t, "standard", values[0].Value) - assert.True(t, values[0].Default) + require.NoError(t, h.RunRaw([]string{"hygiene-models", "-o", "json"})) + assert.Equal(t, `[{"default":true,"description":"","value":"standard"},{"default":false,"description":"","value":"fast"}]`+"\n", h.GetStdout()) assert.Empty(t, h.GetStderr()) + + for _, tc := range catalogOutputCases { + t.Run(tc.command, func(t *testing.T) { + h := NewCLITestHarness(t) + require.NoError(t, h.RunRaw([]string{tc.command, "-o", "json"})) + assert.JSONEq(t, tc.json, h.GetStdout()) + if tc.command == "catalog-legacy" || tc.command == "catalog-noop" { + assert.Equal(t, tc.json+"\n", h.GetStdout()) + } + assert.Empty(t, h.GetStderr()) + + var values []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(h.GetStdout()), &values)) + seen := make(map[string]bool) + for _, value := range values { + name := value["value"].(string) + assert.False(t, seen[name], "enum values must appear only once") + seen[name] = true + } + }) + } } func TestCatalogRejectsPositionalArgument(t *testing.T) { diff --git a/templates/templates/common/common/typeDefs.d.ts b/templates/templates/common/common/typeDefs.d.ts index d07d67dd..d1b543c8 100644 --- a/templates/templates/common/common/typeDefs.d.ts +++ b/templates/templates/common/common/typeDefs.d.ts @@ -281,6 +281,7 @@ declare global { Type: TypeDef; Values: string[]; Descriptions?: Record<string, string>; + Groups?: Record<string, string>; Names: string[]; Open: boolean; Format: "enum" | "union" | ""; @@ -2349,6 +2350,11 @@ declare global { Examples: CLICommandExample[] | null; Help?: CLICommandHelp | null; Hints?: Record<string, string[]>; + CatalogDefaults?: { + CatalogCommand: string; + Value: string; + Label: string; + }[]; }; type CLICommandManifest = { diff --git a/tests/overlays/primary/cli/overlay.yaml b/tests/overlays/primary/cli/overlay.yaml index 95b098f0..ff57b99e 100644 --- a/tests/overlays/primary/cli/overlay.yaml +++ b/tests/overlays/primary/cli/overlay.yaml @@ -73,6 +73,22 @@ actions: defaultFrom: schema summary: Stream events as they arrive commands: + create-widget: + op: cliCatalogOptionsPost + preset: + $.defaults: alpha + $.combined: alpha + inspect-widget: + op: cliCatalogOptionsPost + preset: + $.defaults: alpha + $.combined: alpha + choose-gamma: + op: cliCatalogOptionsPost + preset: + $.defaults: gamma + $.combined: gamma + $.ungrouped: alpha upload: category: Create summary: Upload a multipart file using the operation flags diff --git a/tests/specs/fragments/primary/cli-catalog-options.yaml b/tests/specs/fragments/primary/cli-catalog-options.yaml new file mode 100644 index 00000000..b259a951 --- /dev/null +++ b/tests/specs/fragments/primary/cli-catalog-options.yaml @@ -0,0 +1,125 @@ +openapi: 3.1.0 +security: + - apiKeyAuth: [] + - oauth2: [] + - clientCredentials: [read, write] + - customSchemeAppId: [] + - basicHttp: [] + - accessToken: [] + - {} +paths: + /anything/cli/catalogOptions: + post: + operationId: cliCatalogOptionsPost + description: Enum catalogs whose defaults come from command presets and whose groups retain enum declaration order, including ungrouped remainder values. + tags: [cliCatalogOptions] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + legacy: + $ref: "#/components/schemas/CliCatalogLegacy" + empty: + $ref: "#/components/schemas/CliCatalogEmpty" + defaults: + $ref: "#/components/schemas/CliCatalogDefaults" + groups: + $ref: "#/components/schemas/CliCatalogGroups" + combined: + $ref: "#/components/schemas/CliCatalogCombined" + ungrouped: + $ref: "#/components/schemas/CliCatalogUngrouped" + noop: + $ref: "#/components/schemas/CliCatalogNoop" + other: + $ref: "#/components/schemas/CliCatalogOther" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object +components: + schemas: + CliCatalogLegacy: + type: string + description: A scalar catalog default preserves fixed-width output even for long labels. + enum: [catalog-value-with-a-name-longer-than-forty-two-characters, beta] + x-speakeasy-enum-descriptions: [Long option., Second option.] + x-speakeasy-cli-catalog: + command: catalog-legacy + summary: List legacy options + description: Prints options with the original catalog format. + default: catalog-value-with-a-name-longer-than-forty-two-characters + CliCatalogEmpty: + type: string + description: Empty enum group metadata preserves the ungrouped catalog format. + enum: [alpha, beta] + x-speakeasy-enum-descriptions: [First option., Second option.] + x-speakeasy-enum-groups: {} + x-speakeasy-cli-catalog: + command: catalog-empty + default: alpha + CliCatalogDefaults: + type: string + description: Command presets do not imply a global catalog default, even with a schema default. + default: alpha + enum: [alpha, beta, gamma, delta, epsilon] + x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-cli-catalog: + command: catalog-defaults + CliCatalogGroups: + type: string + description: Value-keyed enum groups follow enum order and retain unmatched values under Other. + enum: [alpha, beta, gamma, delta, epsilon] + x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-enum-groups: + beta: ' Primary "Widgets" ' + alpha: ' Primary "Widgets" ' + gamma: " Secondary " + x-speakeasy-cli-catalog: + command: catalog-groups + default: delta + CliCatalogCombined: + type: string + description: Grouped catalogs retain global default metadata while displaying preset-derived command defaults. + enum: [alpha, beta, gamma, delta, epsilon] + x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-enum-groups: [' Primary "Widgets" ', ' Primary "Widgets" ', " Secondary ", "", ""] + x-speakeasy-cli-catalog: + command: catalog-combined + default: alpha + CliCatalogUngrouped: + type: string + description: Empty group titles leave values ungrouped while retaining preset-derived defaults. + enum: [alpha, beta, gamma] + x-speakeasy-enum-descriptions: [First option., Second option., Third option.] + x-speakeasy-enum-groups: + alpha: " " + beta: "" + x-speakeasy-cli-catalog: + command: catalog-ungrouped + CliCatalogOther: + type: string + description: An explicit Other group retains its first-seen position and merges ungrouped values in enum declaration order. + enum: [alpha, beta, gamma, delta, epsilon] + x-speakeasy-enum-descriptions: [First option., Second option., Third option., Fourth option., Fifth option.] + x-speakeasy-enum-groups: + beta: Other + delta: Primary + x-speakeasy-cli-catalog: + command: catalog-other + CliCatalogNoop: + type: string + description: Empty group titles preserve fixed-width output for long enum values. + enum: [catalog-value-with-a-name-longer-than-forty-two-characters, beta] + x-speakeasy-enum-descriptions: [Long option., Second option.] + x-speakeasy-enum-groups: + beta: " " + x-speakeasy-cli-catalog: + command: catalog-noop + default: catalog-value-with-a-name-longer-than-forty-two-characters