Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,21 @@ All leaf commands are built using one of five generic base types. Always prefer

#### `base.ListCmd[T]`
For listing resources. No args, no flags needed (extend via `BaseCobraCommand` if flags are required).

Prefer `OutputTable` over `PrintText` for table output. When `OutputTable` is set, the base automatically registers `--no-headers` and handles header suppression. `PrintText` is used as a fallback when `OutputTable` is not set.

```go
base.ListCmd[*foov1.ListFoosResponse]{
Use: "list",
Short: "List all foos",
Fetch: func(s *state.State, cmd *cobra.Command) (*foov1.ListFoosResponse, error) {
// call gRPC, return response
},
PrintText: func(_ *cobra.Command, w io.Writer, resp *foov1.ListFoosResponse) error {
OutputTable: func(_ *cobra.Command, w io.Writer, resp *foov1.ListFoosResponse) output.Renderable {
t := output.NewTable[*foov1.Foo](w)
t.AddField("ID", func(v *foov1.Foo) string { return v.GetId() })
t.Write(resp.Items)
return nil
t.SetItems(resp.Items)
return t
},
}.CobraCommand(s)
```
Expand Down
18 changes: 18 additions & 0 deletions internal/cmd/base/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ import (

// ListCmd defines a command for fetching and displaying a list response.
// T is the full response proto message (e.g. *clusterv1.ListClustersResponse).
//
// For table output, prefer OutputTable over PrintText. When OutputTable is set,
// the base automatically registers --no-headers and handles header suppression.
// PrintText is used as a fallback when OutputTable is not set.
type ListCmd[T any] struct {
Use string
Short string
Long string
Example string
Fetch func(s *state.State, cmd *cobra.Command) (T, error)
OutputTable func(cmd *cobra.Command, out io.Writer, resp T) output.TableRenderer
PrintText func(cmd *cobra.Command, out io.Writer, resp T) error
ValidArgsFunction func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)
}
Expand All @@ -37,9 +42,22 @@ func (lc ListCmd[T]) CobraCommand(s *state.State) *cobra.Command {
if s.Config.JSONOutput() {
return output.PrintJSON(cmd.OutOrStdout(), resp)
}
if lc.OutputTable != nil {
r := lc.OutputTable(cmd, cmd.OutOrStdout(), resp)
noHeaders, _ := cmd.Flags().GetBool("no-headers")
r.SetNoHeaders(noHeaders)
r.Render()
return nil
}
if lc.PrintText == nil {
panic("ListCmd: either OutputTable or PrintText must be set")
}
return lc.PrintText(cmd, cmd.OutOrStdout(), resp)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to return error here when both OutputTable and PrintText are not set?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does, the code should panic since it's a development mistake. Adding it!

},
}
if lc.OutputTable != nil {
cmd.Flags().Bool("no-headers", false, "Do not print column headers")
}
if lc.ValidArgsFunction != nil {
cmd.ValidArgsFunction = lc.ValidArgsFunction
}
Expand Down
91 changes: 91 additions & 0 deletions internal/cmd/base/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package base_test

import (
"bytes"
"fmt"
"io"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/qdrant/qcloud-cli/internal/cmd/base"
"github.com/qdrant/qcloud-cli/internal/cmd/output"
"github.com/qdrant/qcloud-cli/internal/state"
)

var fetchHello = func(_ *state.State, _ *cobra.Command) (string, error) { return "hello", nil }

func execListCmd(t *testing.T, lc base.ListCmd[string], args ...string) (string, error) {
t.Helper()
cmd := lc.CobraCommand(state.New(""))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetArgs(args)
err := cmd.Execute()
return buf.String(), err
}

func stringTableRenderer(out io.Writer, val string) output.TableRenderer {
tbl := output.NewTable[string](out)
tbl.AddField("VALUE", func(v string) string { return v })
tbl.SetItems([]string{val})
return tbl
}

func TestListCmd_OutputTable(t *testing.T) {
lc := base.ListCmd[string]{
Use: "test",
Fetch: fetchHello,
OutputTable: func(_ *cobra.Command, out io.Writer, resp string) output.TableRenderer {
return stringTableRenderer(out, resp)
},
}

stdout, err := execListCmd(t, lc)
require.NoError(t, err)
assert.Contains(t, stdout, "VALUE")
assert.Contains(t, stdout, "hello")
}

func TestListCmd_OutputTable_NoHeaders(t *testing.T) {
lc := base.ListCmd[string]{
Use: "test",
Fetch: fetchHello,
OutputTable: func(_ *cobra.Command, out io.Writer, resp string) output.TableRenderer {
return stringTableRenderer(out, resp)
},
}

stdout, err := execListCmd(t, lc, "--no-headers")
require.NoError(t, err)
assert.NotContains(t, stdout, "VALUE")
assert.Contains(t, stdout, "hello")
}

func TestListCmd_PrintText(t *testing.T) {
lc := base.ListCmd[string]{
Use: "test",
Fetch: fetchHello,
PrintText: func(_ *cobra.Command, out io.Writer, resp string) error {
_, err := fmt.Fprintln(out, resp)
return err
},
}

stdout, err := execListCmd(t, lc)
require.NoError(t, err)
assert.Contains(t, stdout, "hello")
}

func TestListCmd_NeitherOutputTableNorPrintText_Panics(t *testing.T) {
lc := base.ListCmd[string]{
Use: "test",
Fetch: fetchHello,
}

assert.Panics(t, func() {
_, _ = execListCmd(t, lc)
})
}
33 changes: 33 additions & 0 deletions internal/cmd/cloudprovider/cloud_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,36 @@ func TestListCloudProviders_TableOutput(t *testing.T) {
require.True(t, ok)
assert.Equal(t, "test-account-id", req.GetAccountId())
}

func TestListCloudProviders_NoHeaders(t *testing.T) {
env := testutil.NewTestEnv(t)

env.PlatformServer.ListCloudProvidersCalls.Returns(&platformv1.ListCloudProvidersResponse{
Items: []*platformv1.CloudProvider{
{Id: "aws", Name: "Amazon Web Services", Available: true},
},
}, nil)

stdout, _, err := testutil.Exec(t, env, "cloud-provider", "list", "--no-headers")
require.NoError(t, err)
assert.NotContains(t, stdout, "ID")
assert.NotContains(t, stdout, "NAME")
assert.NotContains(t, stdout, "AVAILABLE")
assert.Contains(t, stdout, "aws")
assert.Contains(t, stdout, "Amazon Web Services")
}

func TestListCloudProviders_NoHeadersWithJSON(t *testing.T) {
env := testutil.NewTestEnv(t)

env.PlatformServer.ListCloudProvidersCalls.Returns(&platformv1.ListCloudProvidersResponse{
Items: []*platformv1.CloudProvider{
{Id: "aws", Name: "Amazon Web Services", Available: true},
},
}, nil)

stdout, _, err := testutil.Exec(t, env, "cloud-provider", "list", "--no-headers", "--json")
require.NoError(t, err)
assert.Contains(t, stdout, `"id"`)
assert.Contains(t, stdout, `"aws"`)
}
6 changes: 3 additions & 3 deletions internal/cmd/cloudprovider/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func newListCommand(s *state.State) *cobra.Command {

return resp, nil
},
PrintText: func(_ *cobra.Command, w io.Writer, resp *platformv1.ListCloudProvidersResponse) error {
OutputTable: func(_ *cobra.Command, w io.Writer, resp *platformv1.ListCloudProvidersResponse) output.TableRenderer {
t := output.NewTable[*platformv1.CloudProvider](w)
t.AddField("ID", func(v *platformv1.CloudProvider) string {
return v.GetId()
Expand All @@ -50,8 +50,8 @@ func newListCommand(s *state.State) *cobra.Command {
t.AddField("AVAILABLE", func(v *platformv1.CloudProvider) string {
return strconv.FormatBool(v.GetAvailable())
})
t.Write(resp.GetItems())
return nil
t.SetItems(resp.GetItems())
return t
},
}.CobraCommand(s)
}
47 changes: 39 additions & 8 deletions internal/cmd/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,20 @@ import (
"google.golang.org/protobuf/proto"
)

// TableRenderer can render table output with configurable header suppression.
// Table[T] implements this interface after SetItems is called.
type TableRenderer interface {
SetNoHeaders(bool)
Render()
}

// Table renders items as an ASCII table.
type Table[T any] struct {
w io.Writer
headers []string
fields []func(T) string
w io.Writer
headers []string
fields []func(T) string
items []T
noHeaders bool
}

// NewTable creates a new Table that writes to the given writer.
Expand All @@ -28,8 +37,28 @@ func (t *Table[T]) AddField(name string, fn func(T) string) {
t.fields = append(t.fields, fn)
}

// Write renders the table with the given items.
// SetNoHeaders controls whether the header row is suppressed when rendering.
func (t *Table[T]) SetNoHeaders(v bool) {
t.noHeaders = v
}

// SetItems stores items for deferred rendering via Render.
func (t *Table[T]) SetItems(items []T) {
t.items = items
}

// Render writes the table using previously stored items (via SetItems).
// Headers are suppressed when SetNoHeaders(true) has been called.
func (t *Table[T]) Render() {
t.render(t.items)
}

// Write renders the table with the given items immediately.
func (t *Table[T]) Write(items []T) {
t.render(items)
}

func (t *Table[T]) render(items []T) {
style := table.Style{
Name: "minimal",
Box: table.StyleBoxLight,
Expand All @@ -49,11 +78,13 @@ func (t *Table[T]) Write(items []T) {
tw.SetOutputMirror(t.w)
tw.SetStyle(style)

header := make(table.Row, len(t.headers))
for i, h := range t.headers {
header[i] = h
if !t.noHeaders {
header := make(table.Row, len(t.headers))
for i, h := range t.headers {
header[i] = h
}
tw.AppendHeader(header)
}
tw.AppendHeader(header)

for _, item := range items {
row := make(table.Row, len(t.fields))
Expand Down
66 changes: 66 additions & 0 deletions internal/cmd/output/output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package output_test

import (
"bytes"
"testing"

"github.com/stretchr/testify/assert"

"github.com/qdrant/qcloud-cli/internal/cmd/output"
)

type testItem struct {
ID string
Name string
}

func TestTable_Render_WithHeaders(t *testing.T) {
var buf bytes.Buffer
tbl := output.NewTable[testItem](&buf)
tbl.AddField("ID", func(v testItem) string { return v.ID })
tbl.AddField("NAME", func(v testItem) string { return v.Name })
tbl.SetItems([]testItem{
{ID: "1", Name: "alpha"},
})
tbl.Render()

out := buf.String()
assert.Contains(t, out, "ID")
assert.Contains(t, out, "NAME")
assert.Contains(t, out, "1")
assert.Contains(t, out, "alpha")
}

func TestTable_Render_NoHeaders(t *testing.T) {
var buf bytes.Buffer
tbl := output.NewTable[testItem](&buf)
tbl.AddField("ID", func(v testItem) string { return v.ID })
tbl.AddField("NAME", func(v testItem) string { return v.Name })
tbl.SetItems([]testItem{
{ID: "1", Name: "alpha"},
})
tbl.SetNoHeaders(true)
tbl.Render()

out := buf.String()
assert.NotContains(t, out, "ID")
assert.NotContains(t, out, "NAME")
assert.Contains(t, out, "1")
assert.Contains(t, out, "alpha")
}

func TestTable_Write_BackwardCompat(t *testing.T) {
var buf bytes.Buffer
tbl := output.NewTable[testItem](&buf)
tbl.AddField("ID", func(v testItem) string { return v.ID })
tbl.AddField("NAME", func(v testItem) string { return v.Name })
tbl.Write([]testItem{
{ID: "1", Name: "alpha"},
})

out := buf.String()
assert.Contains(t, out, "ID")
assert.Contains(t, out, "NAME")
assert.Contains(t, out, "1")
assert.Contains(t, out, "alpha")
}
Loading