From 5bef9d7c9f6647321f84d6df37d90ece0b75fae8 Mon Sep 17 00:00:00 2001 From: dhernando Date: Wed, 8 Apr 2026 11:38:16 +0200 Subject: [PATCH 1/6] feat: introduce automatic registration of --no-headers on list commands and cloud-provider list to use it --- AGENTS.md | 9 ++- internal/cmd/base/list.go | 15 +++++ .../cmd/cloudprovider/cloud_provider_test.go | 33 ++++++++++ internal/cmd/cloudprovider/list.go | 6 +- internal/cmd/output/output.go | 47 ++++++++++--- internal/cmd/output/output_test.go | 66 +++++++++++++++++++ 6 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 internal/cmd/output/output_test.go diff --git a/AGENTS.md b/AGENTS.md index 1f1445b..ebfeecd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,9 @@ 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", @@ -102,11 +105,11 @@ base.ListCmd[*foov1.ListFoosResponse]{ 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) ``` diff --git a/internal/cmd/base/list.go b/internal/cmd/base/list.go index d03b23b..4528213 100644 --- a/internal/cmd/base/list.go +++ b/internal/cmd/base/list.go @@ -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.Renderable PrintText func(cmd *cobra.Command, out io.Writer, resp T) error ValidArgsFunction func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) } @@ -37,9 +42,19 @@ 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 + } return lc.PrintText(cmd, cmd.OutOrStdout(), resp) }, } + if lc.OutputTable != nil { + cmd.Flags().Bool("no-headers", false, "Do not print column headers") + } if lc.ValidArgsFunction != nil { cmd.ValidArgsFunction = lc.ValidArgsFunction } diff --git a/internal/cmd/cloudprovider/cloud_provider_test.go b/internal/cmd/cloudprovider/cloud_provider_test.go index 7555948..8db75c1 100644 --- a/internal/cmd/cloudprovider/cloud_provider_test.go +++ b/internal/cmd/cloudprovider/cloud_provider_test.go @@ -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"`) +} diff --git a/internal/cmd/cloudprovider/list.go b/internal/cmd/cloudprovider/list.go index fbc1e5c..df9ed42 100644 --- a/internal/cmd/cloudprovider/list.go +++ b/internal/cmd/cloudprovider/list.go @@ -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.Renderable { t := output.NewTable[*platformv1.CloudProvider](w) t.AddField("ID", func(v *platformv1.CloudProvider) string { return v.GetId() @@ -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) } diff --git a/internal/cmd/output/output.go b/internal/cmd/output/output.go index ab88494..b107ff4 100644 --- a/internal/cmd/output/output.go +++ b/internal/cmd/output/output.go @@ -10,11 +10,20 @@ import ( "google.golang.org/protobuf/proto" ) +// Renderable can render table output with configurable header suppression. +// Table[T] implements this interface after SetItems is called. +type Renderable 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. @@ -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, @@ -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)) diff --git a/internal/cmd/output/output_test.go b/internal/cmd/output/output_test.go new file mode 100644 index 0000000..68d8c84 --- /dev/null +++ b/internal/cmd/output/output_test.go @@ -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") +} From 4f5f3fc743fb37da88e2acc12149750d3978b073 Mon Sep 17 00:00:00 2001 From: dhernando Date: Wed, 8 Apr 2026 13:53:33 +0200 Subject: [PATCH 2/6] refactor: rename Renderable to TableRenderer --- internal/cmd/cloudprovider/list.go | 2 +- internal/cmd/output/output.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/cloudprovider/list.go b/internal/cmd/cloudprovider/list.go index df9ed42..8b0dc3d 100644 --- a/internal/cmd/cloudprovider/list.go +++ b/internal/cmd/cloudprovider/list.go @@ -39,7 +39,7 @@ func newListCommand(s *state.State) *cobra.Command { return resp, nil }, - OutputTable: func(_ *cobra.Command, w io.Writer, resp *platformv1.ListCloudProvidersResponse) output.Renderable { + 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() diff --git a/internal/cmd/output/output.go b/internal/cmd/output/output.go index b107ff4..9f07bb3 100644 --- a/internal/cmd/output/output.go +++ b/internal/cmd/output/output.go @@ -10,9 +10,9 @@ import ( "google.golang.org/protobuf/proto" ) -// Renderable can render table output with configurable header suppression. +// TableRenderer can render table output with configurable header suppression. // Table[T] implements this interface after SetItems is called. -type Renderable interface { +type TableRenderer interface { SetNoHeaders(bool) Render() } From ae8caa2d2ed660e276c4c5de997488494bad455c Mon Sep 17 00:00:00 2001 From: dhernando Date: Wed, 8 Apr 2026 13:54:15 +0200 Subject: [PATCH 3/6] fix: rename missing reference to output.Renderable --- internal/cmd/base/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/base/list.go b/internal/cmd/base/list.go index 4528213..3efdeb2 100644 --- a/internal/cmd/base/list.go +++ b/internal/cmd/base/list.go @@ -21,7 +21,7 @@ type ListCmd[T any] struct { 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.Renderable + 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) } From 6d6864dfca0ad579e9d335999606260a195e6d0b Mon Sep 17 00:00:00 2001 From: dhernando Date: Wed, 8 Apr 2026 17:42:11 +0200 Subject: [PATCH 4/6] fix: panic when the user does not set neither OutputTable nor PrintText in ListCmd --- internal/cmd/base/list.go | 3 ++ internal/cmd/base/list_test.go | 89 ++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 internal/cmd/base/list_test.go diff --git a/internal/cmd/base/list.go b/internal/cmd/base/list.go index 3efdeb2..a305628 100644 --- a/internal/cmd/base/list.go +++ b/internal/cmd/base/list.go @@ -49,6 +49,9 @@ func (lc ListCmd[T]) CobraCommand(s *state.State) *cobra.Command { r.Render() return nil } + if lc.PrintText == nil { + panic("ListCmd: either OutputTable or PrintText must be set") + } return lc.PrintText(cmd, cmd.OutOrStdout(), resp) }, } diff --git a/internal/cmd/base/list_test.go b/internal/cmd/base/list_test.go new file mode 100644 index 0000000..8e9538d --- /dev/null +++ b/internal/cmd/base/list_test.go @@ -0,0 +1,89 @@ +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" +) + +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: func(_ *state.State, _ *cobra.Command) (string, error) { return "hello", nil }, + 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: func(_ *state.State, _ *cobra.Command) (string, error) { return "hello", nil }, + 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: func(_ *state.State, _ *cobra.Command) (string, error) { return "hello", nil }, + 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: func(_ *state.State, _ *cobra.Command) (string, error) { return "hello", nil }, + } + + assert.Panics(t, func() { + _, _ = execListCmd(t, lc) + }) +} From 0fb2b8289720eea0dc09805d2dcee1035aed8cae Mon Sep 17 00:00:00 2001 From: dhernando Date: Wed, 8 Apr 2026 17:49:39 +0200 Subject: [PATCH 5/6] fix: avoid coyppasting the fetch function for testing the base list struct --- internal/cmd/base/list_test.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/cmd/base/list_test.go b/internal/cmd/base/list_test.go index 8e9538d..d18332f 100644 --- a/internal/cmd/base/list_test.go +++ b/internal/cmd/base/list_test.go @@ -15,6 +15,8 @@ import ( "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("")) @@ -35,7 +37,7 @@ func stringTableRenderer(out io.Writer, val string) output.TableRenderer { func TestListCmd_OutputTable(t *testing.T) { lc := base.ListCmd[string]{ Use: "test", - Fetch: func(_ *state.State, _ *cobra.Command) (string, error) { return "hello", nil }, + Fetch: fetchHello, OutputTable: func(_ *cobra.Command, out io.Writer, resp string) output.TableRenderer { return stringTableRenderer(out, resp) }, @@ -50,7 +52,7 @@ func TestListCmd_OutputTable(t *testing.T) { func TestListCmd_OutputTable_NoHeaders(t *testing.T) { lc := base.ListCmd[string]{ Use: "test", - Fetch: func(_ *state.State, _ *cobra.Command) (string, error) { return "hello", nil }, + Fetch: fetchHello, OutputTable: func(_ *cobra.Command, out io.Writer, resp string) output.TableRenderer { return stringTableRenderer(out, resp) }, @@ -64,8 +66,8 @@ func TestListCmd_OutputTable_NoHeaders(t *testing.T) { func TestListCmd_PrintText(t *testing.T) { lc := base.ListCmd[string]{ - Use: "test", - Fetch: func(_ *state.State, _ *cobra.Command) (string, error) { return "hello", nil }, + Use: "test", + Fetch: fetchHello, PrintText: func(_ *cobra.Command, out io.Writer, resp string) error { _, err := fmt.Fprintln(out, resp) return err @@ -80,7 +82,7 @@ func TestListCmd_PrintText(t *testing.T) { func TestListCmd_NeitherOutputTableNorPrintText_Panics(t *testing.T) { lc := base.ListCmd[string]{ Use: "test", - Fetch: func(_ *state.State, _ *cobra.Command) (string, error) { return "hello", nil }, + Fetch: fetchHello, } assert.Panics(t, func() { From 948c67d1baaccdaa0411d94a1a59274c567f72c0 Mon Sep 17 00:00:00 2001 From: dhernando Date: Wed, 8 Apr 2026 17:50:13 +0200 Subject: [PATCH 6/6] chore: code format --- internal/cmd/base/list_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/base/list_test.go b/internal/cmd/base/list_test.go index d18332f..3724fe0 100644 --- a/internal/cmd/base/list_test.go +++ b/internal/cmd/base/list_test.go @@ -66,7 +66,7 @@ func TestListCmd_OutputTable_NoHeaders(t *testing.T) { func TestListCmd_PrintText(t *testing.T) { lc := base.ListCmd[string]{ - Use: "test", + Use: "test", Fetch: fetchHello, PrintText: func(_ *cobra.Command, out io.Writer, resp string) error { _, err := fmt.Fprintln(out, resp)