diff --git a/pkg/report/doc.go b/pkg/report/doc.go index 326b315f2..3a1701082 100644 --- a/pkg/report/doc.go +++ b/pkg/report/doc.go @@ -3,34 +3,45 @@ Package report provides helper structs/methods/funcs for formatting output To format output for an array of structs: - w := report.NewWriterDefault(os.Stdout) - defer w.Flush() - +ExamplePodman: headers := report.Headers(struct { ID string }{}, nil) - t, _ := report.NewTemplate("command name").Parse("{{range .}}{{.ID}}{{end}}") - t.Execute(t, headers) - t.Execute(t, map[string]string{ + + f := report.New(os.Stdout, "Command Name") + f, _ := f.Parse(report.OriginPodman, "{{range .}}{{.ID}}{{end}}") + defer f.Flush() + + if f.RenderHeaders { + f.Execute(headers) + } + f.Execute( map[string]string{ "ID":"fa85da03b40141899f3af3de6d27852b", }) - // t.IsTable() == false - -or - w := report.NewWriterDefault(os.Stdout) - defer w.Flush() + // Output: + // ID + // fa85da03b40141899f3af3de6d27852b +ExampleUser: headers := report.Headers(struct { CID string }{}, map[string]string{ "CID":"ID"}) - t, _ := report.NewTemplate("command name").Parse("table {{.CID}}") - t.Execute(t, headers) + + f, _ := report.New(os.Stdout, "Command Name").Parse(report.OriginUser, "table {{.CID}}") + defer f.Flush() + + if f.RenderHeaders { + t.Execute(t, headers) + } t.Execute(t,map[string]string{ "CID":"fa85da03b40141899f3af3de6d27852b", }) - // t.IsTable() == true + + // Output: + // ID + // fa85da03b40141899f3af3de6d27852b Helpers: @@ -38,13 +49,20 @@ Helpers: ... process JSON and output } + if report.HasTable(cmd.Flag("format").Value.String()) { + ... "table" keyword prefix in format text + } + Template Functions: The following template functions are added to the template when parsed: - join strings.Join, {{join .Field separator}} + - json encode field as JSON {{ json .Field }} - lower strings.ToLower {{ .Field | lower }} + - pad add spaces as prefix and suffix {{ pad . 2 2 }} - split strings.Split {{ .Field | split }} - title strings.Title {{ .Field | title }} + - truncate limit field length {{ truncate . 10 }} - upper strings.ToUpper {{ .Field | upper }} report.Funcs() may be used to add additional template functions. diff --git a/pkg/report/formatter.go b/pkg/report/formatter.go new file mode 100644 index 000000000..0f913b20a --- /dev/null +++ b/pkg/report/formatter.go @@ -0,0 +1,133 @@ +package report + +import ( + "io" + "strings" + "text/tabwriter" + "text/template" +) + +// Flusher is the interface that wraps the Flush method. +type Flusher interface { + Flush() error +} + +// NopFlusher represents a type which flush operation is nop. +type NopFlusher struct{} + +// Flush is a nop operation. +func (f *NopFlusher) Flush() (err error) { return } + +type Origin int + +const ( + OriginUnknown Origin = iota + OriginPodman + OriginUser +) + +func (o Origin) String() string { + switch o { + case OriginPodman: + return "OriginPodman" + case OriginUser: + return "OriginUser" + default: + return "OriginUnknown" + } +} + +// Formatter holds the configured Writer and parsed Template, additional state fields are +// maintained to assist in the podman command report writing. +type Formatter struct { + io.Writer // Destination for formatted output + Flusher // Flush any buffered formatted output + *template.Template // Go text/template for formatting output + RenderTable bool // Does template have "table" keyword + RenderHeaders bool // default behavior for given template is to include headers + Origin Origin // Source of go template. OriginUser or OriginPodman + text string +} + +// Parse parses golang template returning a formatter +// +// - OriginPodman implies text is a template from podman code. Output will +// be filtered through a tabwriter. +// +// - OriginUser implies text is a template from a user. If template includes +// keyword "table" output will be filtered through a tabwriter. +func (f *Formatter) Parse(origin Origin, text string) (*Formatter, error) { + f.Origin = origin + + if strings.HasPrefix(text, "table ") { + f.RenderTable = true + text = "{{range .}}" + NormalizeFormat(text) + "{{end -}}" + } else { + text = NormalizeFormat(text) + } + f.text = text + + if f.RenderTable || origin == OriginPodman { + tw := tabwriter.NewWriter(f.Writer, 12, 2, 2, ' ', tabwriter.StripEscape) + f.Writer = tw + f.Flusher = tw + f.RenderHeaders = true + } + + tmpl, err := f.Template.Funcs(template.FuncMap(DefaultFuncs)).Parse(text) + if err != nil { + return f, err + } + f.Template = tmpl + return f, nil +} + +// Funcs adds the elements of the argument map to the template's function map. +// A default template function will be replaced if there is a key collision. +func (f *Formatter) Funcs(funcMap template.FuncMap) *Formatter { + m := make(template.FuncMap, len(DefaultFuncs)+len(funcMap)) + for k, v := range DefaultFuncs { + m[k] = v + } + for k, v := range funcMap { + m[k] = v + } + f.Template = f.Template.Funcs(funcMap) + return f +} + +// Init either resets the given tabwriter with new values or wraps w in tabwriter with given values +func (f *Formatter) Init(w io.Writer, minwidth, tabwidth, padding int, padchar byte, flags uint) *Formatter { + flags |= tabwriter.StripEscape + + if tw, ok := f.Writer.(*tabwriter.Writer); ok { + tw = tw.Init(w, minwidth, tabwidth, padding, padchar, flags) + f.Writer = tw + f.Flusher = tw + } else { + tw = tabwriter.NewWriter(w, minwidth, tabwidth, padding, padchar, flags) + f.Writer = tw + f.Flusher = tw + } + return f +} + +// Execute applies a parsed template to the specified data object, +// and writes the output to f.Writer. +func (f *Formatter) Execute(data interface{}) error { + return f.Template.Execute(f.Writer, data) +} + +// New allocates a new, undefined Formatter with the given name and Writer +func New(output io.Writer, name string) *Formatter { + f := new(Formatter) + + f.Flusher = new(NopFlusher) + if flusher, ok := output.(Flusher); ok { + f.Flusher = flusher + } + + f.Template = template.New(name) + f.Writer = output + return f +} diff --git a/pkg/report/formatter_test.go b/pkg/report/formatter_test.go new file mode 100644 index 000000000..58a7a0252 --- /dev/null +++ b/pkg/report/formatter_test.go @@ -0,0 +1,202 @@ +package report + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + "text/tabwriter" + "text/template" + + "github.com/stretchr/testify/assert" +) + +func TestFormatter_IsTableFalse(t *testing.T) { + fmtr, err := New(os.Stdout, t.Name()).Parse(OriginPodman, "{{.ID}}") + assert.NoError(t, err) + assert.False(t, fmtr.RenderTable) +} + +func TestFormatter_IsTableTrue(t *testing.T) { + fmtr, err := New(os.Stdout, t.Name()).Parse(OriginPodman, "table {{.ID}}") + assert.NoError(t, err) + assert.True(t, fmtr.RenderTable) +} + +func TestFormatter_HasTable(t *testing.T) { + assert.True(t, HasTable("table foobar")) + assert.False(t, HasTable("foobar")) +} + +type testFormatterStruct struct { + FieldA bool // camel case test + Fieldb bool // no camel case + fieldC bool // nolint // private field + fieldd bool // nolint // private field +} + +func TestFormatter_HeadersNoOverrides(t *testing.T) { + expected := []map[string]string{{ + "FieldA": "FIELD A", + "Fieldb": "FIELDB", + "fieldC": "FIELD C", + "fieldd": "FIELDD", + }} + assert.Equal(t, expected, Headers(testFormatterStruct{}, nil)) +} + +func TestFormatter_HeadersOverride(t *testing.T) { + expected := []map[string]string{{ + "FieldA": "FIELD A", + "Fieldb": "FIELD B", + "fieldC": "FIELD C", + "fieldd": "FIELD D", + }} + assert.Equal(t, expected, Headers(testFormatterStruct{}, map[string]string{ + "Fieldb": "field b", + "fieldd": "field d", + })) +} + +func TestFormatter_ParseTable(t *testing.T) { + testCase := []struct { + Type io.Writer + Origin Origin + Format string + Expected string + }{ + {&tabwriter.Writer{}, OriginUser, "table {{ .ID}}", "Identity\nc061a0839e\nf10fc2e11057\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\n"}, + {&tabwriter.Writer{}, OriginUser, "table {{ .ID}}\n", "Identity\nc061a0839e\nf10fc2e11057\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\n"}, + {&tabwriter.Writer{}, OriginUser, `table {{ .ID}}\n`, "Identity\nc061a0839e\nf10fc2e11057\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\n"}, + {&tabwriter.Writer{}, OriginUser, "table {{.ID}}", "Identity\nc061a0839e\nf10fc2e11057\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\n"}, + {&tabwriter.Writer{}, OriginUser, "table {{.ID}}\t{{.Value}}", + "Identity Value\nc061a0839e one\nf10fc2e11057 two\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a three\n"}, + {&bytes.Buffer{}, OriginUser, "{{range .}}{{.ID}}\tID{{end}}", "c061a0839e\tIDf10fc2e11057\tID1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\tID\n"}, + {&tabwriter.Writer{}, OriginPodman, "Value\tIdent\n{{range .}}{{.ID}}\tID{{end}}", + "Value Ident\nIdentity ID\nValue Ident\nc061a0839e IDf10fc2e11057 ID1eb6fab5aa8f4b5cbfd3e66aa35e9b2a ID\n"}, + {&bytes.Buffer{}, OriginUser, "{{range .}}{{.ID}}\tID\n{{end}}", "c061a0839e\tID\nf10fc2e11057\tID\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\tID\n\n"}, + {&bytes.Buffer{}, OriginUser, `{{range .}}{{.ID}}{{end}}`, "c061a0839ef10fc2e110571eb6fab5aa8f4b5cbfd3e66aa35e9b2a\n"}, + } + + for loop, tc := range testCase { + tc := tc + name := fmt.Sprintf("%s Loop#%d", t.Name(), loop) + t.Run(name, func(t *testing.T) { + buf := new(bytes.Buffer) + + fmtr, err := New(buf, name).Parse(tc.Origin, tc.Format) + assert.NoError(t, err) + assert.Equal(t, tc.Origin, fmtr.Origin) + assert.IsType(t, tc.Type, fmtr.Writer) + + if fmtr.RenderHeaders { + fmtr.Execute([]map[string]string{{ + "ID": "Identity", + "Value": "Value", + }}) + } + err = fmtr.Execute([...]map[string]string{ + {"ID": "c061a0839e", "Value": "one"}, + {"ID": "f10fc2e11057", "Value": "two"}, + {"ID": "1eb6fab5aa8f4b5cbfd3e66aa35e9b2a", "Value": "three"}, + }) + assert.NoError(t, err) + fmtr.Flush() + assert.Equal(t, tc.Expected, buf.String(), fmt.Sprintf("%+q, %+q", tc.Format, fmtr.text)) + }) + } +} + +func TestFormatter_Init(t *testing.T) { + data := [...]map[string]string{ + {"ID": "c061a0839e", "Value": "one"}, + {"ID": "f10fc2e11057", "Value": "two"}, + {"ID": "1eb6fab5aa8f4b5cbfd3e66aa35e9b2a", "Value": "three"}, + } + + buf := new(bytes.Buffer) + fmtr, err := New(buf, t.Name()).Parse(OriginPodman, "{{range .}}{{.ID}}\t{{.Value}}\n{{end -}}") + assert.NoError(t, err) + + err = fmtr.Execute([]map[string]string{{ + "ID": "Identity", + "Value": "Value", + }}) + assert.NoError(t, err) + err = fmtr.Execute(data) + assert.NoError(t, err) + fmtr.Flush() + assert.Equal(t, + "Identity Value\nc061a0839e one\nf10fc2e11057 two\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a three\n", buf.String()) + + buf = new(bytes.Buffer) + fmtr = fmtr.Init(buf, 8, 1, 1, ' ', tabwriter.Debug) + + err = fmtr.Execute([]map[string]string{{ + "ID": "Identity", + "Value": "Value", + }}) + assert.NoError(t, err) + err = fmtr.Execute(data) + assert.NoError(t, err) + fmtr.Flush() + assert.Equal(t, "Identity |Value\nc061a0839e |one\nf10fc2e11057 |two\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a |three\n", buf.String()) +} + +func TestFormatter_FuncsTrim(t *testing.T) { + buf := new(bytes.Buffer) + fmtr := New(buf, t.Name()) + + fmtr, err := fmtr.Funcs(template.FuncMap{"trim": strings.TrimSpace}).Parse(OriginPodman, "{{.ID |trim}}") + assert.NoError(t, err) + + err = fmtr.Execute(map[string]string{ + "ID": "ident ", + }) + assert.NoError(t, err) + assert.Equal(t, "ident\n", buf.String()) +} + +func TestFormatter_FuncsJoin(t *testing.T) { + buf := new(bytes.Buffer) + // Add 'trim' function to ensure default 'join' function is still available + fmtr, e := New(buf, t.Name()).Funcs(template.FuncMap{"trim": strings.TrimSpace}).Parse(OriginPodman, `{{join .ID "-"}}`) + assert.NoError(t, e) + + err := fmtr.Execute(map[string][]string{ + "ID": {"ident1", "ident2", "ident3"}, + }) + assert.NoError(t, err) + assert.Equal(t, "ident1-ident2-ident3\n", buf.String()) +} + +func TestFormatter_FuncsReplace(t *testing.T) { + buf := new(bytes.Buffer) + fmtr := New(buf, t.Name()) + + // yes, we're overriding ToUpper with ToLower :-) + tmpl, e := fmtr.Funcs(template.FuncMap{"upper": strings.ToLower}).Parse(OriginPodman, `{{.ID | lower}}`) + assert.NoError(t, e) + + err := tmpl.Execute(map[string]string{ + "ID": "IDENT", + }) + assert.NoError(t, err) + assert.Equal(t, "ident\n", buf.String()) +} + +func TestFormatter_FuncsJSON(t *testing.T) { + buf := new(bytes.Buffer) + fmtr := New(buf, t.Name()) + + tmpl, e := fmtr.Parse(OriginUser, `{{json .ID}}`) + assert.NoError(t, e) + + err := tmpl.Execute(map[string][]string{ + "ID": {"ident1", "ident2", "ident3"}, + }) + assert.NoError(t, err) + assert.Equal(t, `["ident1","ident2","ident3"]`+"\n", buf.String()) +} diff --git a/pkg/report/template.go b/pkg/report/template.go index f86b07034..3ced08757 100644 --- a/pkg/report/template.go +++ b/pkg/report/template.go @@ -25,17 +25,19 @@ var tableReplacer = strings.NewReplacer( "table ", "", `\t`, "\t", " ", "\t", + `\n`, "\n", ) // escapedReplacer will clean up escaped characters from CLI var escapedReplacer = strings.NewReplacer( `\t`, "\t", + `\n`, "\n", ) var DefaultFuncs = FuncMap{ "join": strings.Join, "json": func(v interface{}) string { - buf := &bytes.Buffer{} + buf := new(bytes.Buffer) enc := json.NewEncoder(buf) enc.SetEscapeHTML(false) enc.Encode(v) diff --git a/pkg/report/template_test.go b/pkg/report/template_test.go index db75a6fab..cf111c9a1 100644 --- a/pkg/report/template_test.go +++ b/pkg/report/template_test.go @@ -112,8 +112,8 @@ func TestTemplate_trim(t *testing.T) { func TestTemplate_DefaultFuncs(t *testing.T) { tmpl := NewTemplate("TestTemplate") - // Throw in trim function to ensure default 'join' is still available - tmpl, e := tmpl.Funcs(FuncMap{"trim": strings.TrimSpace}).Parse(`{{join .ID "\n"}}`) + // Throw in trim function to ensure default 'join' is still available, Sprintf used to ovoid nasty escaping + tmpl, e := tmpl.Funcs(FuncMap{"trim": strings.TrimSpace}).Parse(`{{join .ID "-"}}`) assert.NoError(t, e) var buf bytes.Buffer @@ -121,7 +121,7 @@ func TestTemplate_DefaultFuncs(t *testing.T) { "ID": {"ident1", "ident2", "ident3"}, }) assert.NoError(t, err) - assert.Equal(t, "ident1\nident2\nident3\n", buf.String()) + assert.Equal(t, "ident1-ident2-ident3\n", buf.String()) } func TestTemplate_ReplaceFuncs(t *testing.T) {