-
-
Notifications
You must be signed in to change notification settings - Fork 375
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Anbraten <6918444+anbraten@users.noreply.github.com>
- Loading branch information
Showing
13 changed files
with
2,475 additions
and
70 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
package output | ||
|
||
import ( | ||
"errors" | ||
"strings" | ||
) | ||
|
||
var ErrOutputOptionRequired = errors.New("output option required") | ||
|
||
func ParseOutputOptions(out string) (string, []string) { | ||
out, opt, found := strings.Cut(out, "=") | ||
|
||
if !found { | ||
return out, nil | ||
} | ||
|
||
var optList []string | ||
|
||
if opt != "" { | ||
optList = strings.Split(opt, ",") | ||
} | ||
|
||
return out, optList | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
package output | ||
|
||
import ( | ||
"fmt" | ||
"io" | ||
"reflect" | ||
"sort" | ||
"strings" | ||
"text/tabwriter" | ||
"unicode" | ||
|
||
"github.com/mitchellh/mapstructure" | ||
) | ||
|
||
// NewTable creates a new Table. | ||
func NewTable(out io.Writer) *Table { | ||
padding := 2 | ||
|
||
return &Table{ | ||
w: tabwriter.NewWriter(out, 0, 0, padding, ' ', 0), | ||
columns: map[string]bool{}, | ||
fieldMapping: map[string]FieldFn{}, | ||
fieldAlias: map[string]string{}, | ||
allowedFields: map[string]bool{}, | ||
} | ||
} | ||
|
||
type FieldFn func(obj any) string | ||
|
||
type writerFlusher interface { | ||
io.Writer | ||
Flush() error | ||
} | ||
|
||
// Table is a generic way to format object as a table. | ||
type Table struct { | ||
w writerFlusher | ||
columns map[string]bool | ||
fieldMapping map[string]FieldFn | ||
fieldAlias map[string]string | ||
allowedFields map[string]bool | ||
} | ||
|
||
// Columns returns a list of known output columns. | ||
func (o *Table) Columns() (cols []string) { | ||
for c := range o.columns { | ||
cols = append(cols, c) | ||
} | ||
sort.Strings(cols) | ||
return | ||
} | ||
|
||
// AddFieldAlias overrides the field name to allow custom column headers. | ||
func (o *Table) AddFieldAlias(field, alias string) *Table { | ||
o.fieldAlias[field] = alias | ||
return o | ||
} | ||
|
||
// AddFieldFn adds a function which handles the output of the specified field. | ||
func (o *Table) AddFieldFn(field string, fn FieldFn) *Table { | ||
o.fieldMapping[field] = fn | ||
o.allowedFields[field] = true | ||
o.columns[field] = true | ||
return o | ||
} | ||
|
||
// AddAllowedFields reads all first level fieldnames of the struct and allows them to be used. | ||
func (o *Table) AddAllowedFields(obj any) (*Table, error) { | ||
v := reflect.ValueOf(obj) | ||
if v.Kind() != reflect.Struct { | ||
return o, fmt.Errorf("AddAllowedFields input must be a struct") | ||
} | ||
t := v.Type() | ||
for i := 0; i < v.NumField(); i++ { | ||
k := t.Field(i).Type.Kind() | ||
if k != reflect.Bool && | ||
k != reflect.Float32 && | ||
k != reflect.Float64 && | ||
k != reflect.String && | ||
k != reflect.Int && | ||
k != reflect.Int64 { | ||
// only allow simple values | ||
// complex values need to be mapped via a FieldFn | ||
continue | ||
} | ||
o.allowedFields[strings.ToLower(t.Field(i).Name)] = true | ||
o.allowedFields[fieldName(t.Field(i).Name)] = true | ||
o.columns[fieldName(t.Field(i).Name)] = true | ||
} | ||
return o, nil | ||
} | ||
|
||
// RemoveAllowedField removes fields from the allowed list. | ||
func (o *Table) RemoveAllowedField(fields ...string) *Table { | ||
for _, field := range fields { | ||
delete(o.allowedFields, field) | ||
delete(o.columns, field) | ||
} | ||
return o | ||
} | ||
|
||
// ValidateColumns returns an error if invalid columns are specified. | ||
func (o *Table) ValidateColumns(cols []string) error { | ||
var invalidCols []string | ||
for _, col := range cols { | ||
if _, ok := o.allowedFields[strings.ToLower(col)]; !ok { | ||
invalidCols = append(invalidCols, col) | ||
} | ||
} | ||
if len(invalidCols) > 0 { | ||
return fmt.Errorf("invalid table columns: %s", strings.Join(invalidCols, ",")) | ||
} | ||
return nil | ||
} | ||
|
||
// WriteHeader writes the table header. | ||
func (o *Table) WriteHeader(columns []string) { | ||
var header []string | ||
for _, col := range columns { | ||
if alias, ok := o.fieldAlias[col]; ok { | ||
col = alias | ||
} | ||
header = append(header, strings.ReplaceAll(strings.ToUpper(col), "_", " ")) | ||
} | ||
_, _ = fmt.Fprintln(o.w, strings.Join(header, "\t")) | ||
} | ||
|
||
func (o *Table) Flush() error { | ||
return o.w.Flush() | ||
} | ||
|
||
// Write writes a table line. | ||
func (o *Table) Write(columns []string, obj any) error { | ||
var data map[string]any | ||
|
||
if err := mapstructure.Decode(obj, &data); err != nil { | ||
return fmt.Errorf("failed to decode object: %w", err) | ||
} | ||
|
||
dataL := map[string]any{} | ||
for key, value := range data { | ||
dataL[strings.ToLower(key)] = value | ||
} | ||
|
||
var out []string | ||
for _, col := range columns { | ||
colName := strings.ToLower(col) | ||
if alias, ok := o.fieldAlias[colName]; ok { | ||
if fn, ok := o.fieldMapping[alias]; ok { | ||
out = append(out, fn(obj)) | ||
continue | ||
} | ||
} | ||
if fn, ok := o.fieldMapping[colName]; ok { | ||
out = append(out, fn(obj)) | ||
continue | ||
} | ||
if value, ok := dataL[strings.ReplaceAll(colName, "_", "")]; ok { | ||
if value == nil { | ||
out = append(out, NA("")) | ||
continue | ||
} | ||
if b, ok := value.(bool); ok { | ||
out = append(out, YesNo(b)) | ||
continue | ||
} | ||
if s, ok := value.(string); ok { | ||
out = append(out, NA(s)) | ||
continue | ||
} | ||
out = append(out, fmt.Sprintf("%v", value)) | ||
} | ||
} | ||
_, _ = fmt.Fprintln(o.w, strings.Join(out, "\t")) | ||
|
||
return nil | ||
} | ||
|
||
func NA(s string) string { | ||
if s == "" { | ||
return "-" | ||
} | ||
return s | ||
} | ||
|
||
func YesNo(b bool) string { | ||
if b { | ||
return "yes" | ||
} | ||
return "no" | ||
} | ||
|
||
func fieldName(name string) string { | ||
r := []rune(name) | ||
var out []rune | ||
for i := range r { | ||
if i > 0 && (unicode.IsUpper(r[i])) && (i+1 < len(r) && unicode.IsLower(r[i+1]) || unicode.IsLower(r[i-1])) { | ||
out = append(out, '_') | ||
} | ||
out = append(out, unicode.ToLower(r[i])) | ||
} | ||
return string(out) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
package output | ||
|
||
import ( | ||
"bytes" | ||
"os" | ||
"strings" | ||
"testing" | ||
) | ||
|
||
type writerFlusherStub struct { | ||
bytes.Buffer | ||
} | ||
|
||
func (s writerFlusherStub) Flush() error { | ||
return nil | ||
} | ||
|
||
type testFieldsStruct struct { | ||
Name string | ||
Number int | ||
} | ||
|
||
func TestTableOutput(t *testing.T) { | ||
var wfs writerFlusherStub | ||
to := NewTable(os.Stdout) | ||
to.w = &wfs | ||
|
||
t.Run("AddAllowedFields", func(t *testing.T) { | ||
_, _ = to.AddAllowedFields(testFieldsStruct{}) | ||
if _, ok := to.allowedFields["name"]; !ok { | ||
t.Error("name should be a allowed field") | ||
} | ||
}) | ||
t.Run("AddFieldAlias", func(t *testing.T) { | ||
to.AddFieldAlias("woodpecker_ci", "woodpecker ci") | ||
if alias, ok := to.fieldAlias["woodpecker_ci"]; !ok || alias != "woodpecker ci" { | ||
t.Errorf("woodpecker_ci alias should be 'woodpecker ci', is: %v", alias) | ||
} | ||
}) | ||
t.Run("AddFieldOutputFn", func(t *testing.T) { | ||
to.AddFieldFn("woodpecker ci", FieldFn(func(_ any) string { | ||
return "WOODPECKER CI!!!" | ||
})) | ||
if _, ok := to.fieldMapping["woodpecker ci"]; !ok { | ||
t.Errorf("'woodpecker ci' field output fn should be set") | ||
} | ||
}) | ||
t.Run("ValidateColumns", func(t *testing.T) { | ||
err := to.ValidateColumns([]string{"non-existent", "NAME"}) | ||
if err == nil || | ||
strings.Contains(err.Error(), "name") || | ||
!strings.Contains(err.Error(), "non-existent") { | ||
t.Errorf("error should contain 'non-existent' but not 'name': %v", err) | ||
} | ||
}) | ||
t.Run("WriteHeader", func(t *testing.T) { | ||
to.WriteHeader([]string{"woodpecker_ci", "name"}) | ||
if wfs.String() != "WOODPECKER CI\tNAME\n" { | ||
t.Errorf("written header should be 'WOODPECKER CI\\tNAME\\n', is: %q", wfs.String()) | ||
} | ||
wfs.Reset() | ||
}) | ||
t.Run("WriteLine", func(t *testing.T) { | ||
_ = to.Write([]string{"woodpecker_ci", "name", "number"}, &testFieldsStruct{"test123", 1000000000}) | ||
if wfs.String() != "WOODPECKER CI!!!\ttest123\t1000000000\n" { | ||
t.Errorf("written line should be 'WOODPECKER CI!!!\\ttest123\\t1000000000\\n', is: %q", wfs.String()) | ||
} | ||
wfs.Reset() | ||
}) | ||
t.Run("Columns", func(t *testing.T) { | ||
if len(to.Columns()) != 3 { | ||
t.Errorf("unexpected number of columns: %v", to.Columns()) | ||
} | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.