diff --git a/cli/format/json/writer.go b/cli/format/json/writer.go new file mode 100644 index 0000000..da1df92 --- /dev/null +++ b/cli/format/json/writer.go @@ -0,0 +1,38 @@ +package json + +import ( + "encoding/json" + "io" + + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cli/format" +) + +const Format format.Format = "json" + +func init() { + format.Register(Format, NewWriter()) +} + +type Writer struct{} + +// Format implements format.Writer. +func (*Writer) Write(writer io.Writer, hints format.Hints, data ...any) error { + encoder := json.NewEncoder(writer) + + if hints.OutputMode == format.OutputModeWide { + encoder.SetIndent("", " ") + } + + if err := encoder.Encode(data); err != nil { + return errors.WithStack(err) + } + + return nil +} + +func NewWriter() *Writer { + return &Writer{} +} + +var _ format.Writer = &Writer{} diff --git a/cli/format/prop.go b/cli/format/prop.go new file mode 100644 index 0000000..0881bd5 --- /dev/null +++ b/cli/format/prop.go @@ -0,0 +1,49 @@ +package format + +type PropHintName string + +type PropHintFunc func() (PropHintName, any) + +type Prop struct { + name string + label string + hints map[PropHintName]any +} + +func (p *Prop) Name() string { + return p.name +} + +func (p *Prop) Label() string { + return p.label +} + +func NewProp(name, label string, funcs ...PropHintFunc) Prop { + hints := make(map[PropHintName]any) + for _, fn := range funcs { + name, value := fn() + hints[name] = value + } + + return Prop{name, label, hints} +} + +func WithPropHint(name PropHintName, value any) PropHintFunc { + return func() (PropHintName, any) { + return name, value + } +} + +func PropHint[T any](p Prop, name PropHintName, defaultValue T) T { + rawValue, exists := p.hints[name] + if !exists { + return defaultValue + } + + value, ok := rawValue.(T) + if !ok { + return defaultValue + } + + return value +} diff --git a/cli/format/registry.go b/cli/format/registry.go new file mode 100644 index 0000000..bfe1e58 --- /dev/null +++ b/cli/format/registry.go @@ -0,0 +1,46 @@ +package format + +import ( + "io" + + "github.com/pkg/errors" +) + +type Format string + +type Registry map[Format]Writer + +var defaultRegistry = Registry{} + +var ErrUnknownFormat = errors.New("unknown format") + +func Write(format Format, writer io.Writer, hints Hints, data ...any) error { + formatWriter, exists := defaultRegistry[format] + if !exists { + return errors.WithStack(ErrUnknownFormat) + } + + if hints.OutputMode == "" { + hints.OutputMode = OutputModeCompact + } + + if err := formatWriter.Write(writer, hints, data...); err != nil { + return errors.WithStack(err) + } + + return nil +} + +func Available() []Format { + formats := make([]Format, 0, len(defaultRegistry)) + + for f := range defaultRegistry { + formats = append(formats, f) + } + + return formats +} + +func Register(format Format, writer Writer) { + defaultRegistry[format] = writer +} diff --git a/cli/format/table/prop.go b/cli/format/table/prop.go new file mode 100644 index 0000000..92e6839 --- /dev/null +++ b/cli/format/table/prop.go @@ -0,0 +1,61 @@ +package table + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cli/format" +) + +const ( + hintCompactModeMaxColumnWidth format.PropHintName = "compactModeMaxColumnWidth" +) + +func WithCompactModeMaxColumnWidth(max int) format.PropHintFunc { + return format.WithPropHint(hintCompactModeMaxColumnWidth, max) +} + +func getProps(d any) []format.Prop { + props := make([]format.Prop, 0) + + v := reflect.Indirect(reflect.ValueOf(d)) + typeOf := v.Type() + + for i := 0; i < v.NumField(); i++ { + name := typeOf.Field(i).Name + props = append(props, format.NewProp(name, name)) + } + + return props +} + +func getFieldValue(obj any, name string) string { + v := reflect.Indirect(reflect.ValueOf(obj)) + + fieldValue := v.FieldByName(name) + + if !fieldValue.IsValid() { + return "" + } + + switch fieldValue.Kind() { + case reflect.Map: + fallthrough + case reflect.Struct: + fallthrough + case reflect.Slice: + fallthrough + case reflect.Interface: + json, err := json.Marshal(fieldValue.Interface()) + if err != nil { + panic(errors.WithStack(err)) + } + + return string(json) + + default: + return fmt.Sprintf("%v", fieldValue) + } +} diff --git a/cli/format/table/writer.go b/cli/format/table/writer.go new file mode 100644 index 0000000..22fd89b --- /dev/null +++ b/cli/format/table/writer.go @@ -0,0 +1,80 @@ +package table + +import ( + "io" + + "github.com/jedib0t/go-pretty/v6/table" + "gitlab.com/wpetit/goweb/cli/format" +) + +const Format format.Format = "table" + +const DefaultCompactModeMaxColumnWidth = 30 + +func init() { + format.Register(Format, NewWriter(DefaultCompactModeMaxColumnWidth)) +} + +type Writer struct { + compactModeMaxColumnWidth int +} + +// Write implements format.Writer. +func (w *Writer) Write(writer io.Writer, hints format.Hints, data ...any) error { + t := table.NewWriter() + + t.SetOutputMirror(writer) + + var props []format.Prop + + if hints.Props != nil { + props = hints.Props + } else { + if len(data) > 0 { + props = getProps(data[0]) + } else { + props = make([]format.Prop, 0) + } + } + + labels := table.Row{} + + for _, p := range props { + labels = append(labels, p.Label()) + } + + t.AppendHeader(labels) + + isCompactMode := hints.OutputMode == format.OutputModeCompact + + for _, d := range data { + row := table.Row{} + + for _, p := range props { + value := getFieldValue(d, p.Name()) + + compactModeMaxColumnWidth := format.PropHint(p, + hintCompactModeMaxColumnWidth, + w.compactModeMaxColumnWidth, + ) + + if isCompactMode && len(value) > compactModeMaxColumnWidth { + value = value[:compactModeMaxColumnWidth] + "..." + } + + row = append(row, value) + } + + t.AppendRow(row) + } + + t.Render() + + return nil +} + +func NewWriter(compactModeMaxColumnWidth int) *Writer { + return &Writer{compactModeMaxColumnWidth} +} + +var _ format.Writer = &Writer{} diff --git a/cli/format/table/writer_test.go b/cli/format/table/writer_test.go new file mode 100644 index 0000000..a7b91db --- /dev/null +++ b/cli/format/table/writer_test.go @@ -0,0 +1,86 @@ +package table + +import ( + "bytes" + "strings" + "testing" + + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cli/format" +) + +type dummyItem struct { + MyString string + MyInt int + MySub subItem +} + +type subItem struct { + MyBool bool +} + +var dummyItems = []any{ + dummyItem{ + MyString: "Foo", + MyInt: 1, + MySub: subItem{ + MyBool: false, + }, + }, + dummyItem{ + MyString: "Bar", + MyInt: 0, + MySub: subItem{ + MyBool: true, + }, + }, +} + +func TestWriterNoHints(t *testing.T) { + var buf bytes.Buffer + + writer := NewWriter(DefaultCompactModeMaxColumnWidth) + + if err := writer.Write(&buf, format.Hints{}, dummyItems...); err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + expected := `+----------+-------+------------------+ +| MYSTRING | MYINT | MYSUB | ++----------+-------+------------------+ +| Foo | 1 | {"MyBool":false} | +| Bar | 0 | {"MyBool":true} | ++----------+-------+------------------+` + + if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g { + t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g) + } +} + +func TestWriterWithPropHints(t *testing.T) { + var buf bytes.Buffer + + writer := NewWriter(DefaultCompactModeMaxColumnWidth) + + hints := format.Hints{ + Props: []format.Prop{ + format.NewProp("MyString", "MyString"), + format.NewProp("MyInt", "MyInt"), + }, + } + + if err := writer.Write(&buf, hints, dummyItems...); err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + expected := `+----------+-------+ +| MYSTRING | MYINT | ++----------+-------+ +| Foo | 1 | +| Bar | 0 | ++----------+-------+` + + if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g { + t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g) + } +} diff --git a/cli/format/writer.go b/cli/format/writer.go new file mode 100644 index 0000000..bfc214a --- /dev/null +++ b/cli/format/writer.go @@ -0,0 +1,19 @@ +package format + +import "io" + +type OutputMode string + +const ( + OutputModeWide OutputMode = "wide" + OutputModeCompact OutputMode = "compact" +) + +type Hints struct { + Props []Prop + OutputMode OutputMode +} + +type Writer interface { + Write(writer io.Writer, hints Hints, data ...any) error +} diff --git a/go.mod b/go.mod index 8c0f28b..69c5fb4 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/jedib0t/go-pretty/v6 v6.4.9 github.com/leodido/go-urn v1.2.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/go.sum b/go.sum index a9e80b0..b8471d3 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jedib0t/go-pretty/v6 v6.4.9 h1:vZ6bjGg2eBSrJn365qlxGcaWu09Id+LHtrfDWlB2Usc= +github.com/jedib0t/go-pretty/v6 v6.4.9/go.mod h1:Ndk3ase2CkQbXLLNf5QDHoYb6J9WtVfmHZu9n8rk2xs= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -125,6 +127,7 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -139,6 +142,7 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -157,6 +161,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -231,6 +236,7 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=