Skip to content

Commit bc23a1f

Browse files
authored
feat: use OutputTable with --no-headers support in all list commands (#112)
* feat: use OutputTable with --no-headers support in all list commands * fix: bring back PrintText so that parallel work doesn't fail * feat: implement the rest of lists using OutputTable Furthermore, removed the PrintText option in ListCmd to avoid inconsistencies. Also updated AGENTS.md with up to date examples.
1 parent 6e83875 commit bc23a1f

25 files changed

+163
-122
lines changed

AGENTS.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,7 @@ Each subcommand group lives in `internal/cmd/<group>/`:
9494
All leaf commands are built using one of five generic base types. Always prefer these over raw `cobra.Command`.
9595

9696
#### `base.ListCmd[T]`
97-
For listing resources. No args, no flags needed (extend via `BaseCobraCommand` if flags are required).
98-
99-
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.
97+
For listing resources. `OutputTable` must be set. The base automatically registers `--no-headers` and handles header suppression. By default the command takes no positional args; set `Args` to accept them.
10098

10199
```go
102100
base.ListCmd[*foov1.ListFoosResponse]{
@@ -105,11 +103,11 @@ base.ListCmd[*foov1.ListFoosResponse]{
105103
Fetch: func(s *state.State, cmd *cobra.Command) (*foov1.ListFoosResponse, error) {
106104
// call gRPC, return response
107105
},
108-
OutputTable: func(_ *cobra.Command, w io.Writer, resp *foov1.ListFoosResponse) output.Renderable {
106+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *foov1.ListFoosResponse) (output.TableRenderer, error) {
109107
t := output.NewTable[*foov1.Foo](w)
110108
t.AddField("ID", func(v *foov1.Foo) string { return v.GetId() })
111-
t.SetItems(resp.Items)
112-
return t
109+
t.SetItems(resp.GetItems())
110+
return t, nil
113111
},
114112
}.CobraCommand(s)
115113
```

internal/cmd/account/list.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ qcloud account list --json`,
3434

3535
return client.Account().ListAccounts(ctx, &accountv1.ListAccountsRequest{})
3636
},
37-
PrintText: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountsResponse) error {
37+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountsResponse) (output.TableRenderer, error) {
3838
t := output.NewTable[*accountv1.Account](w)
3939
t.AddField("ID", func(v *accountv1.Account) string { return v.GetId() })
4040
t.AddField("NAME", func(v *accountv1.Account) string { return v.GetName() })
@@ -45,8 +45,8 @@ qcloud account list --json`,
4545
}
4646
return ""
4747
})
48-
t.Write(resp.GetItems())
49-
return nil
48+
t.SetItems(resp.GetItems())
49+
return t, nil
5050
},
5151
}.CobraCommand(s)
5252
}

internal/cmd/account/list_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,19 @@ func TestAccountList_Empty(t *testing.T) {
8484
assert.Contains(t, stdout, "ID")
8585
assert.Contains(t, stdout, "NAME")
8686
}
87+
88+
func TestAccountList_NoHeaders(t *testing.T) {
89+
env := testutil.NewTestEnv(t)
90+
91+
env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{
92+
Items: []*accountv1.Account{
93+
{Id: "acct-001", Name: "Production"},
94+
},
95+
}, nil)
96+
97+
stdout, _, err := testutil.Exec(t, env, "account", "list", "--no-headers")
98+
require.NoError(t, err)
99+
assert.NotContains(t, stdout, "ID")
100+
assert.NotContains(t, stdout, "NAME")
101+
assert.Contains(t, stdout, "acct-001")
102+
}

internal/cmd/account/member_list.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ qcloud account member list --json`,
4848

4949
return resp, nil
5050
},
51-
PrintText: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountMembersResponse) error {
51+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountMembersResponse) (output.TableRenderer, error) {
5252
t := output.NewTable[*accountv1.AccountMember](w)
5353
t.AddField("ID", func(v *accountv1.AccountMember) string {
5454
return v.GetAccountMember().GetId()
@@ -65,8 +65,8 @@ qcloud account member list --json`,
6565
}
6666
return ""
6767
})
68-
t.Write(resp.GetItems())
69-
return nil
68+
t.SetItems(resp.GetItems())
69+
return t, nil
7070
},
7171
}.CobraCommand(s)
7272
}

internal/cmd/account/member_list_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,19 @@ func TestMemberList_Empty(t *testing.T) {
106106
assert.Contains(t, stdout, "ID")
107107
assert.Contains(t, stdout, "EMAIL")
108108
}
109+
110+
func TestMemberList_NoHeaders(t *testing.T) {
111+
env := testutil.NewTestEnv(t)
112+
113+
env.AccountServer.ListAccountMembersCalls.Returns(&accountv1.ListAccountMembersResponse{
114+
Items: []*accountv1.AccountMember{
115+
{AccountMember: &iamv1.User{Id: "user-001", Email: "owner@example.com"}},
116+
},
117+
}, nil)
118+
119+
stdout, _, err := testutil.Exec(t, env, "account", "member", "list", "--no-headers")
120+
require.NoError(t, err)
121+
assert.NotContains(t, stdout, "ID")
122+
assert.NotContains(t, stdout, "EMAIL")
123+
assert.Contains(t, stdout, "user-001")
124+
}

internal/cmd/backup/list.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func newListCommand(s *state.State) *cobra.Command {
4242
}
4343
return resp, nil
4444
},
45-
PrintText: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupsResponse) error {
45+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupsResponse) (output.TableRenderer, error) {
4646
t := output.NewTable[*backupv1.Backup](w)
4747
t.AddField("ID", func(v *backupv1.Backup) string {
4848
return v.GetId()
@@ -62,8 +62,8 @@ func newListCommand(s *state.State) *cobra.Command {
6262
}
6363
return ""
6464
})
65-
t.Write(resp.GetItems())
66-
return nil
65+
t.SetItems(resp.GetItems())
66+
return t, nil
6767
},
6868
}.CobraCommand(s)
6969

internal/cmd/backup/restore_list.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func newRestoreListCommand(s *state.State) *cobra.Command {
4242
}
4343
return resp, nil
4444
},
45-
PrintText: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupRestoresResponse) error {
45+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupRestoresResponse) (output.TableRenderer, error) {
4646
t := output.NewTable[*backupv1.BackupRestore](w)
4747
t.AddField("ID", func(v *backupv1.BackupRestore) string {
4848
return v.GetId()
@@ -62,8 +62,8 @@ func newRestoreListCommand(s *state.State) *cobra.Command {
6262
}
6363
return ""
6464
})
65-
t.Write(resp.GetItems())
66-
return nil
65+
t.SetItems(resp.GetItems())
66+
return t, nil
6767
},
6868
}.CobraCommand(s)
6969

internal/cmd/backup/schedule_list.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func newScheduleListCommand(s *state.State) *cobra.Command {
4242
}
4343
return resp, nil
4444
},
45-
PrintText: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupSchedulesResponse) error {
45+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupSchedulesResponse) (output.TableRenderer, error) {
4646
t := output.NewTable[*backupv1.BackupSchedule](w)
4747
t.AddField("ID", func(v *backupv1.BackupSchedule) string {
4848
return v.GetId()
@@ -68,8 +68,8 @@ func newScheduleListCommand(s *state.State) *cobra.Command {
6868
}
6969
return ""
7070
})
71-
t.Write(resp.GetItems())
72-
return nil
71+
t.SetItems(resp.GetItems())
72+
return t, nil
7373
},
7474
}.CobraCommand(s)
7575

internal/cmd/base/list.go

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,31 @@ import (
1212
// ListCmd defines a command for fetching and displaying a list response.
1313
// T is the full response proto message (e.g. *clusterv1.ListClustersResponse).
1414
//
15-
// For table output, prefer OutputTable over PrintText. When OutputTable is set,
16-
// the base automatically registers --no-headers and handles header suppression.
17-
// PrintText is used as a fallback when OutputTable is not set.
15+
// OutputTable must be set. When set, --no-headers is automatically registered
16+
// and handled.
1817
type ListCmd[T any] struct {
1918
Use string
2019
Short string
2120
Long string
2221
Example string
22+
Args cobra.PositionalArgs // optional; defaults to cobra.NoArgs
2323
Fetch func(s *state.State, cmd *cobra.Command) (T, error)
24-
OutputTable func(cmd *cobra.Command, out io.Writer, resp T) output.TableRenderer
25-
PrintText func(cmd *cobra.Command, out io.Writer, resp T) error
24+
OutputTable func(cmd *cobra.Command, out io.Writer, resp T) (output.TableRenderer, error)
2625
ValidArgsFunction func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)
2726
}
2827

2928
// CobraCommand builds a cobra.Command from this ListCmd.
3029
func (lc ListCmd[T]) CobraCommand(s *state.State) *cobra.Command {
30+
posArgs := lc.Args
31+
if posArgs == nil {
32+
posArgs = cobra.NoArgs
33+
}
3134
cmd := &cobra.Command{
3235
Use: lc.Use,
3336
Short: lc.Short,
3437
Long: lc.Long,
3538
Example: lc.Example,
36-
Args: cobra.NoArgs,
39+
Args: posArgs,
3740
RunE: func(cmd *cobra.Command, args []string) error {
3841
resp, err := lc.Fetch(s, cmd)
3942
if err != nil {
@@ -42,22 +45,17 @@ func (lc ListCmd[T]) CobraCommand(s *state.State) *cobra.Command {
4245
if s.Config.JSONOutput() {
4346
return output.PrintJSON(cmd.OutOrStdout(), resp)
4447
}
45-
if lc.OutputTable != nil {
46-
r := lc.OutputTable(cmd, cmd.OutOrStdout(), resp)
47-
noHeaders, _ := cmd.Flags().GetBool("no-headers")
48-
r.SetNoHeaders(noHeaders)
49-
r.Render()
50-
return nil
51-
}
52-
if lc.PrintText == nil {
53-
panic("ListCmd: either OutputTable or PrintText must be set")
48+
r, err := lc.OutputTable(cmd, cmd.OutOrStdout(), resp)
49+
if err != nil {
50+
return err
5451
}
55-
return lc.PrintText(cmd, cmd.OutOrStdout(), resp)
52+
noHeaders, _ := cmd.Flags().GetBool("no-headers")
53+
r.SetNoHeaders(noHeaders)
54+
r.Render()
55+
return nil
5656
},
5757
}
58-
if lc.OutputTable != nil {
59-
cmd.Flags().Bool("no-headers", false, "Do not print column headers")
60-
}
58+
cmd.Flags().Bool("no-headers", false, "Do not print column headers")
6159
if lc.ValidArgsFunction != nil {
6260
cmd.ValidArgsFunction = lc.ValidArgsFunction
6361
}

internal/cmd/base/list_test.go

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package base_test
22

33
import (
44
"bytes"
5-
"fmt"
65
"io"
76
"testing"
87

@@ -38,8 +37,8 @@ func TestListCmd_OutputTable(t *testing.T) {
3837
lc := base.ListCmd[string]{
3938
Use: "test",
4039
Fetch: fetchHello,
41-
OutputTable: func(_ *cobra.Command, out io.Writer, resp string) output.TableRenderer {
42-
return stringTableRenderer(out, resp)
40+
OutputTable: func(_ *cobra.Command, out io.Writer, resp string) (output.TableRenderer, error) {
41+
return stringTableRenderer(out, resp), nil
4342
},
4443
}
4544

@@ -49,43 +48,34 @@ func TestListCmd_OutputTable(t *testing.T) {
4948
assert.Contains(t, stdout, "hello")
5049
}
5150

52-
func TestListCmd_OutputTable_NoHeaders(t *testing.T) {
51+
func TestListCmd_WithArgs(t *testing.T) {
5352
lc := base.ListCmd[string]{
54-
Use: "test",
55-
Fetch: fetchHello,
56-
OutputTable: func(_ *cobra.Command, out io.Writer, resp string) output.TableRenderer {
57-
return stringTableRenderer(out, resp)
53+
Use: "test <value>",
54+
Args: cobra.ExactArgs(1),
55+
Fetch: func(_ *state.State, cmd *cobra.Command) (string, error) {
56+
return cmd.Flags().Arg(0), nil
57+
},
58+
OutputTable: func(_ *cobra.Command, out io.Writer, resp string) (output.TableRenderer, error) {
59+
return stringTableRenderer(out, resp), nil
5860
},
5961
}
6062

61-
stdout, err := execListCmd(t, lc, "--no-headers")
63+
stdout, err := execListCmd(t, lc, "world")
6264
require.NoError(t, err)
63-
assert.NotContains(t, stdout, "VALUE")
64-
assert.Contains(t, stdout, "hello")
65+
assert.Contains(t, stdout, "world")
6566
}
6667

67-
func TestListCmd_PrintText(t *testing.T) {
68+
func TestListCmd_OutputTable_NoHeaders(t *testing.T) {
6869
lc := base.ListCmd[string]{
6970
Use: "test",
7071
Fetch: fetchHello,
71-
PrintText: func(_ *cobra.Command, out io.Writer, resp string) error {
72-
_, err := fmt.Fprintln(out, resp)
73-
return err
72+
OutputTable: func(_ *cobra.Command, out io.Writer, resp string) (output.TableRenderer, error) {
73+
return stringTableRenderer(out, resp), nil
7474
},
7575
}
7676

77-
stdout, err := execListCmd(t, lc)
77+
stdout, err := execListCmd(t, lc, "--no-headers")
7878
require.NoError(t, err)
79+
assert.NotContains(t, stdout, "VALUE")
7980
assert.Contains(t, stdout, "hello")
8081
}
81-
82-
func TestListCmd_NeitherOutputTableNorPrintText_Panics(t *testing.T) {
83-
lc := base.ListCmd[string]{
84-
Use: "test",
85-
Fetch: fetchHello,
86-
}
87-
88-
assert.Panics(t, func() {
89-
_, _ = execListCmd(t, lc)
90-
})
91-
}

0 commit comments

Comments
 (0)