diff --git a/pkg/cmd/gpusearch/gpusearch.go b/pkg/cmd/gpusearch/gpusearch.go index a00aa3ce..11d69bff 100644 --- a/pkg/cmd/gpusearch/gpusearch.go +++ b/pkg/cmd/gpusearch/gpusearch.go @@ -121,7 +121,11 @@ type GPUSearchStore interface { var ( searchLong = `Search instance types available on Brev. -Use 'brev search gpu' (default) to find GPU instances. +The search command has two subcommands: + gpu Search GPU instance types (default when no subcommand is given) + cpu Search CPU-only instance types + +Use 'brev search' or 'brev search gpu' to find GPU instances. Use 'brev search cpu' to find CPU-only instances. Features column shows instance capabilities: @@ -213,9 +217,28 @@ func NewCmdGPUSearch(t *terminal.Terminal, store GPUSearchStore) *cobra.Command Use: "search", Aliases: []string{}, DisableFlagsInUseLine: true, - Short: "Search and filter instance types", + Short: "Search GPU and CPU instance types", Long: searchLong, Example: gpuExample, + ValidArgs: []string{"cpu", "gpu"}, + Args: func(cmd *cobra.Command, args []string) error { + // The parent runs GPU search by default when no positional args are + // given. Unknown positional tokens (e.g. "cpus", "CPU", "badcmd") + // do not match the cpu/gpu subcommands, so cobra falls back to the + // parent; reject them with a helpful error instead of silently + // running GPU search. + if len(args) == 0 { + return nil + } + path := cmd.CommandPath() + return breverrors.NewValidationError(fmt.Sprintf( + "unknown subcommand %q for %q.\n"+ + "Available subcommands: cpu, gpu.\n"+ + "Use %q (default) or %q gpu for GPU instances; use %q cpu for CPU-only instances.\n"+ + "Run %q --help for more details.", + args[0], path, path, path, path, path, + )) + }, RunE: func(cmd *cobra.Command, args []string) error { // Default behavior: GPU search return RunGPUSearch(t, store, gpuName, shared.provider, shared.arch, minVRAM, minTotalVRAM, minCapability, shared.minRAM, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput, wide) diff --git a/pkg/cmd/gpusearch/gpusearch_test.go b/pkg/cmd/gpusearch/gpusearch_test.go index cbc40f1d..5749d3a6 100644 --- a/pkg/cmd/gpusearch/gpusearch_test.go +++ b/pkg/cmd/gpusearch/gpusearch_test.go @@ -3,6 +3,7 @@ package gpusearch import ( "testing" + "github.com/brevdev/brev-cli/pkg/terminal" "github.com/stretchr/testify/assert" ) @@ -625,3 +626,82 @@ func TestAllInstanceTypesResponseLookup(t *testing.T) { assert.False(t, resp.HasInstanceType("")) }) } + +// trackingStore is a GPUSearchStore mock that records the includeCPU flag passed to +// GetInstanceTypes so command dispatch tests can assert which search path executed. +// It returns an empty (but non-nil) response by default so RunGPUSearch/RunCPUSearch +// hit the empty-results path and return nil without rendering a table. +type trackingStore struct { + Response *InstanceTypesResponse + Err error + calls []bool +} + +func (m *trackingStore) GetInstanceTypes(includeCPU bool) (*InstanceTypesResponse, error) { + m.calls = append(m.calls, includeCPU) + if m.Err != nil { + return nil, m.Err + } + if m.Response != nil { + return m.Response, nil + } + return &InstanceTypesResponse{Items: []InstanceType{}}, nil +} + +func TestSearchCommandDefaultsToGPUSearchWhenNoArgs(t *testing.T) { + store := &trackingStore{} + cmd := NewCmdGPUSearch(terminal.New(), store) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Len(t, store.calls, 1, "default search should call the store once") + assert.False(t, store.calls[0], "default search should request GPU instances (includeCPU=false)") +} + +func TestSearchCommandGPUSubcommandDispatchesToGPUSearch(t *testing.T) { + store := &trackingStore{} + cmd := NewCmdGPUSearch(terminal.New(), store) + cmd.SetArgs([]string{"gpu"}) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Len(t, store.calls, 1, "gpu subcommand should call the store once") + assert.False(t, store.calls[0], "gpu subcommand should request GPU instances (includeCPU=false)") +} + +func TestSearchCommandCPUSubcommandDispatchesToCPUSearch(t *testing.T) { + store := &trackingStore{} + cmd := NewCmdGPUSearch(terminal.New(), store) + cmd.SetArgs([]string{"cpu"}) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Len(t, store.calls, 1, "cpu subcommand should call the store once") + assert.True(t, store.calls[0], "cpu subcommand should request CPU instances (includeCPU=true)") +} + +func TestSearchCommandRejectsUnknownPositionalTokens(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"plural cpus", []string{"cpus"}}, + {"uppercase CPU", []string{"CPU"}}, + {"uppercase GPU", []string{"GPU"}}, + {"arbitrary badcmd", []string{"badcmd"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &trackingStore{} + cmd := NewCmdGPUSearch(terminal.New(), store) + cmd.SetArgs(tt.args) + + err := cmd.Execute() + assert.Error(t, err) + assert.ErrorContains(t, err, "unknown subcommand") + assert.ErrorContains(t, err, "Available subcommands: cpu, gpu") + assert.Empty(t, store.calls, "unknown subcommand should not call the store") + }) + } +}