Skip to content

Commit 457cd51

Browse files
committed
chore: make output formatting generic
1 parent 4155b08 commit 457cd51

File tree

5 files changed

+249
-91
lines changed

5 files changed

+249
-91
lines changed

cli/cliui/output.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package cliui
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"reflect"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
"golang.org/x/xerrors"
11+
)
12+
13+
type OutputFormat interface {
14+
ID() string
15+
AttachFlags(cmd *cobra.Command)
16+
Format(ctx context.Context, data any) (string, error)
17+
}
18+
19+
type OutputFormatter struct {
20+
formats []OutputFormat
21+
formatID string
22+
}
23+
24+
// NewOutputFormatter creates a new OutputFormatter with the given formats. The
25+
// first format is the default format. At least two formats must be provided.
26+
func NewOutputFormatter(formats ...OutputFormat) *OutputFormatter {
27+
if len(formats) < 2 {
28+
panic("at least two output formats must be provided")
29+
}
30+
31+
formatIDs := make(map[string]struct{}, len(formats))
32+
for _, format := range formats {
33+
if _, ok := formatIDs[format.ID()]; ok {
34+
panic("duplicate format ID: " + format.ID())
35+
}
36+
formatIDs[format.ID()] = struct{}{}
37+
}
38+
39+
return &OutputFormatter{
40+
formats: formats,
41+
formatID: formats[0].ID(),
42+
}
43+
}
44+
45+
// AttachFlags attaches the --output flag to the given command, and any
46+
// additional flags required by the output formatters.
47+
func (f *OutputFormatter) AttachFlags(cmd *cobra.Command) {
48+
for _, format := range f.formats {
49+
format.AttachFlags(cmd)
50+
}
51+
52+
formatNames := make([]string, 0, len(f.formats))
53+
for _, format := range f.formats {
54+
formatNames = append(formatNames, format.ID())
55+
}
56+
57+
cmd.Flags().StringVarP(&f.formatID, "output", "o", f.formats[0].ID(), "Output format. Available formats: "+strings.Join(formatNames, ", "))
58+
}
59+
60+
// Format formats the given data using the format specified by the --output
61+
// flag. If the flag is not set, the default format is used.
62+
func (f *OutputFormatter) Format(ctx context.Context, data any) (string, error) {
63+
for _, format := range f.formats {
64+
if format.ID() == f.formatID {
65+
return format.Format(ctx, data)
66+
}
67+
}
68+
69+
return "", xerrors.Errorf("unknown output format %q", f.formatID)
70+
}
71+
72+
type tableFormat struct {
73+
defaultColumns []string
74+
allColumns []string
75+
sort string
76+
77+
columns []string
78+
}
79+
80+
var _ OutputFormat = &tableFormat{}
81+
82+
// TableFormat creates a table formatter for the given output type. The output
83+
// type should be specified as an empty slice of the desired type.
84+
//
85+
// E.g.: TableFormat([]MyType{}, []string{"foo", "bar"})
86+
//
87+
// defaultColumns is optional and specifies the default columns to display. If
88+
// not specified, all columns are displayed by default.
89+
func TableFormat(out any, defaultColumns []string) OutputFormat {
90+
v := reflect.Indirect(reflect.ValueOf(out))
91+
if v.Kind() != reflect.Slice {
92+
panic("DisplayTable called with a non-slice type")
93+
}
94+
95+
// Get the list of table column headers.
96+
headers, defaultSort, err := typeToTableHeaders(v.Type().Elem())
97+
if err != nil {
98+
panic("parse table headers: " + err.Error())
99+
}
100+
101+
tf := &tableFormat{
102+
defaultColumns: headers,
103+
allColumns: headers,
104+
sort: defaultSort,
105+
}
106+
if len(defaultColumns) > 0 {
107+
tf.defaultColumns = defaultColumns
108+
}
109+
110+
return tf
111+
}
112+
113+
// ID implements OutputFormat.
114+
func (*tableFormat) ID() string {
115+
return "table"
116+
}
117+
118+
// AttachFlags implements OutputFormat.
119+
func (f *tableFormat) AttachFlags(cmd *cobra.Command) {
120+
cmd.Flags().StringSliceVarP(&f.columns, "column", "c", f.defaultColumns, "Columns to display in table output. Available columns: "+strings.Join(f.allColumns, ", "))
121+
}
122+
123+
// Format implements OutputFormat.
124+
func (f *tableFormat) Format(_ context.Context, data any) (string, error) {
125+
return DisplayTable(data, f.sort, f.columns)
126+
}
127+
128+
type jsonFormat struct{}
129+
130+
var _ OutputFormat = jsonFormat{}
131+
132+
// JSONFormat creates a JSON formatter.
133+
func JSONFormat() OutputFormat {
134+
return jsonFormat{}
135+
}
136+
137+
// ID implements OutputFormat.
138+
func (jsonFormat) ID() string {
139+
return "json"
140+
}
141+
142+
// AttachFlags implements OutputFormat.
143+
func (jsonFormat) AttachFlags(_ *cobra.Command) {}
144+
145+
// Format implements OutputFormat.
146+
func (jsonFormat) Format(_ context.Context, data any) (string, error) {
147+
outBytes, err := json.MarshalIndent(data, "", " ")
148+
if err != nil {
149+
return "", xerrors.Errorf("marshal output to JSON: %w", err)
150+
}
151+
152+
return string(outBytes), nil
153+
}

cli/cliui/table.go

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ func Table() table.Writer {
2222
return tableWriter
2323
}
2424

25-
// FilterTableColumns returns configurations to hide columns
25+
// filterTableColumns returns configurations to hide columns
2626
// that are not provided in the array. If the array is empty,
2727
// no filtering will occur!
28-
func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig {
28+
func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig {
2929
if len(columns) == 0 {
3030
return nil
3131
}
@@ -51,6 +51,9 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig
5151
// of structs. At least one field in the struct must have a `table:""` tag
5252
// containing the name of the column in the outputted table.
5353
//
54+
// If `sort` is not specified, the field with the `table:"$NAME,default_sort"`
55+
// tag will be used to sort. An error will be returned if no field has this tag.
56+
//
5457
// Nested structs are processed if the field has the `table:"$NAME,recursive"`
5558
// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is
5659
// malformed or a field is marked as recursive but does not contain a struct or
@@ -67,13 +70,16 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
6770
}
6871

6972
// Get the list of table column headers.
70-
headersRaw, err := typeToTableHeaders(v.Type().Elem())
73+
headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem())
7174
if err != nil {
7275
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
7376
}
7477
if len(headersRaw) == 0 {
7578
return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`)
7679
}
80+
if sort == "" {
81+
sort = defaultSort
82+
}
7783
headers := make(table.Row, len(headersRaw))
7884
for i, header := range headersRaw {
7985
headers[i] = header
@@ -128,7 +134,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
128134
// Setup the table formatter.
129135
tw := Table()
130136
tw.AppendHeader(headers)
131-
tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns))
137+
tw.SetColumnConfigs(filterTableColumns(headers, filterColumns))
132138
if sort != "" {
133139
tw.SortBy([]table.SortBy{{
134140
Name: sort,
@@ -182,29 +188,32 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
182188
// returned. If the table tag is malformed, an error is returned.
183189
//
184190
// The returned name is transformed from "snake_case" to "normal text".
185-
func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) {
191+
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, err error) {
186192
tags, err := structtag.Parse(string(field.Tag))
187193
if err != nil {
188-
return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
194+
return "", false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
189195
}
190196

191197
tag, err := tags.Get("table")
192198
if err != nil || tag.Name == "-" {
193199
// tags.Get only returns an error if the tag is not found.
194-
return "", false, nil
200+
return "", false, false, nil
195201
}
196202

197-
recursive := false
203+
defaultSortOpt := false
204+
recursiveOpt := false
198205
for _, opt := range tag.Options {
199-
if opt == "recursive" {
200-
recursive = true
201-
continue
206+
switch opt {
207+
case "default_sort":
208+
defaultSortOpt = true
209+
case "recursive":
210+
recursiveOpt = true
211+
default:
212+
return "", false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
202213
}
203-
204-
return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt)
205214
}
206215

207-
return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil
216+
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, nil
208217
}
209218

210219
func isStructOrStructPointer(t reflect.Type) bool {
@@ -214,34 +223,41 @@ func isStructOrStructPointer(t reflect.Type) bool {
214223
// typeToTableHeaders converts a type to a slice of column names. If the given
215224
// type is invalid (not a struct or a pointer to a struct, has invalid table
216225
// tags, etc.), an error is returned.
217-
func typeToTableHeaders(t reflect.Type) ([]string, error) {
226+
func typeToTableHeaders(t reflect.Type) ([]string, string, error) {
218227
if !isStructOrStructPointer(t) {
219-
return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
228+
return nil, "", xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
220229
}
221230
if t.Kind() == reflect.Pointer {
222231
t = t.Elem()
223232
}
224233

225234
headers := []string{}
235+
defaultSortName := ""
226236
for i := 0; i < t.NumField(); i++ {
227237
field := t.Field(i)
228-
name, recursive, err := parseTableStructTag(field)
238+
name, defaultSort, recursive, err := parseTableStructTag(field)
229239
if err != nil {
230-
return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
240+
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
231241
}
232242
if name == "" {
233243
continue
234244
}
245+
if defaultSort {
246+
if defaultSortName != "" {
247+
return nil, "", xerrors.Errorf("multiple fields marked as default sort in type %q", t.String())
248+
}
249+
defaultSortName = name
250+
}
235251

236252
fieldType := field.Type
237253
if recursive {
238254
if !isStructOrStructPointer(fieldType) {
239-
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
255+
return nil, "", xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
240256
}
241257

242-
childNames, err := typeToTableHeaders(fieldType)
258+
childNames, _, err := typeToTableHeaders(fieldType)
243259
if err != nil {
244-
return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
260+
return nil, "", xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
245261
}
246262
for _, childName := range childNames {
247263
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
@@ -252,7 +268,11 @@ func typeToTableHeaders(t reflect.Type) ([]string, error) {
252268
headers = append(headers, name)
253269
}
254270

255-
return headers, nil
271+
if defaultSortName == "" {
272+
return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String())
273+
}
274+
275+
return headers, defaultSortName, nil
256276
}
257277

258278
// valueToTableMap converts a struct to a map of column name to value. If the
@@ -276,7 +296,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
276296
for i := 0; i < val.NumField(); i++ {
277297
field := val.Type().Field(i)
278298
fieldVal := val.Field(i)
279-
name, recursive, err := parseTableStructTag(field)
299+
name, _, recursive, err := parseTableStructTag(field)
280300
if err != nil {
281301
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
282302
}
@@ -309,18 +329,3 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
309329

310330
return row, nil
311331
}
312-
313-
// TableHeaders returns the table header names of all
314-
// fields in tSlice. tSlice must be a slice of some type.
315-
func TableHeaders(tSlice any) ([]string, error) {
316-
v := reflect.Indirect(reflect.ValueOf(tSlice))
317-
rawHeaders, err := typeToTableHeaders(v.Type().Elem())
318-
if err != nil {
319-
return nil, xerrors.Errorf("type to table headers: %w", err)
320-
}
321-
out := make([]string, 0, len(rawHeaders))
322-
for _, hdr := range rawHeaders {
323-
out = append(out, strings.Replace(hdr, " ", "_", -1))
324-
}
325-
return out, nil
326-
}

cli/list.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,8 @@ func list() *cobra.Command {
131131
},
132132
}
133133

134-
availColumns, err := cliui.TableHeaders(displayWorkspaces)
135-
if err != nil {
136-
panic(err)
137-
}
134+
// TODO: fix this
135+
availColumns := []string{"name"}
138136
columnString := strings.Join(availColumns[:], ", ")
139137

140138
cmd.Flags().BoolVarP(&all, "all", "a", false,

0 commit comments

Comments
 (0)