Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions lib/asciitable/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,28 @@ func ExampleMakeTable() {
// Write the table to stdout.
fmt.Println(t.AsBuffer().String())
}

func ExampleMakeColumnsAndRows() {
type dbResourceRow struct {
DatabaseName string `asciitable:"DB Name"` // This column will appear in the table under a custom name.
Skip string `asciitable:"-"` // This column will be skipped entirely.
ResourceID string // It will derive the name "Resource ID"
}

rows := []dbResourceRow{
{DatabaseName: "orders", Skip: "ignored", ResourceID: "db-1"},
{DatabaseName: "users", Skip: "ignored", ResourceID: "db-2"},
}

// Build table columns + rows.
cols, data, err := MakeColumnsAndRows(rows, nil)
if err != nil {
panic(err)
}

// Create asciitable table.
table := MakeTable(cols, data...)

// Write the table to stdout.
fmt.Println(table.AsBuffer().String())
}
93 changes: 93 additions & 0 deletions lib/asciitable/struct.go
Comment thread
tangyatsu marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package asciitable

import (
"fmt"
"reflect"
"regexp"
"slices"

"github.com/gravitational/trace"
)

const asciitableTag = "asciitable"

// Regular expression to convert from "DatabaseRoles" to "Database Roles" etc.
var headerSplitRe = regexp.MustCompile(`([a-z])([A-Z])`)

// MakeColumnsAndRows converts a slice of structs into column headers and
// row data suitable for use with asciitable.MakeTable.
// T must be a struct type. If T is not a struct, the function returns an error.
//
// Column headers are determined by the `asciitable` struct tag. If the tag is
// empty, the header is derived from the field name (e.g., "DatabaseRoles"
// becomes "Database Roles").
//
// includeColumns optionally restricts which columns are returned. Each value
// must match the final header name (tag value is used if present, otherwise the
// derived name). If includeColumns is empty or nil, all fields are included.
func MakeColumnsAndRows[T any](rows []T, includeColumns []string) ([]string, [][]string, error) {
Comment thread
tangyatsu marked this conversation as resolved.
t := reflect.TypeOf((*T)(nil)).Elem()
if t.Kind() != reflect.Struct {
return nil, nil, trace.Errorf("only slices of struct are supported: got slice of %s", t.Kind())
}

type fieldInfo struct {
index int
name string
}

var fields []fieldInfo
var columns []string

for i := 0; i < t.NumField(); i++ {
f := t.Field(i)

header := f.Tag.Get(asciitableTag)
if header == "-" {
continue
}
if header == "" {
header = headerSplitRe.ReplaceAllString(f.Name, "${1} ${2}")
}

if len(includeColumns) > 0 && !slices.Contains(includeColumns, header) {
continue
}

fields = append(fields, fieldInfo{
index: i,
name: header,
})
columns = append(columns, header)
}

outRows := make([][]string, 0, len(rows))
for _, row := range rows {
v := reflect.ValueOf(row)
rowValues := make([]string, 0, len(fields))
for _, fi := range fields {
rowValues = append(rowValues, fmt.Sprintf("%v", v.Field(fi.index)))
}
outRows = append(outRows, rowValues)
}

return columns, outRows, nil
}
178 changes: 178 additions & 0 deletions lib/asciitable/struct_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package asciitable

import (
"errors"
"testing"

"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
)

func TestMakeColumnsAndRows(t *testing.T) {
type row struct {
Name string
ResourceID string
}

rows := []row{
{Name: "n1", ResourceID: "id1"},
{Name: "n2", ResourceID: "id2"},
}

cols, data, err := MakeColumnsAndRows(rows, nil)
require.NoError(t, err)

require.Equal(t, []string{"Name", "Resource ID"}, cols)
require.Equal(t, [][]string{
{"n1", "id1"},
{"n2", "id2"},
}, data)
}

func TestMakeColumnsAndRowsWithTagsAndSkip(t *testing.T) {
type row struct {
Name string `asciitable:"Custom Name"`
Skip string `asciitable:"-"`
ResourceID string `asciitable:"Resource ID"`
}

rows := []row{
{Name: "n1", Skip: "skip1", ResourceID: "id1"},
{Name: "n2", Skip: "skip2", ResourceID: "id2"},
}

cols, data, err := MakeColumnsAndRows(rows, nil)
require.NoError(t, err)

require.Equal(t, []string{"Custom Name", "Resource ID"}, cols)
require.Equal(t, [][]string{
{"n1", "id1"},
{"n2", "id2"},
}, data)
}

func TestMakeColumnsAndRowsIncludeColumns(t *testing.T) {
type row struct {
Name string
Hostname string
Labels string
ResourceID string
}

rows := []row{
{Name: "n1", Hostname: "h1", Labels: "a=1", ResourceID: "id1"},
{Name: "n2", Hostname: "h2", Labels: "b=2", ResourceID: "id2"},
}

cols, data, err := MakeColumnsAndRows(rows, []string{"Name", "Labels"})
require.NoError(t, err)

require.Equal(t, []string{"Name", "Labels"}, cols)
require.Equal(t, [][]string{
{"n1", "a=1"},
{"n2", "b=2"},
}, data)
}

func TestMakeColumnsAndRowsIncludeColumnsWithTags(t *testing.T) {
type row struct {
Name string `asciitable:"Custom Name"`
ResourceID string `asciitable:"Resource ID"`
}

rows := []row{
{Name: "n1", ResourceID: "id1"},
{Name: "n2", ResourceID: "id2"},
}

cols, data, err := MakeColumnsAndRows(rows, []string{"Custom Name"})
require.NoError(t, err)

require.Equal(t, []string{"Custom Name"}, cols)
require.Equal(t, [][]string{
{"n1"},
{"n2"},
}, data)
}

func TestMakeColumnsAndRowsCamelCaseLongName(t *testing.T) {
type row struct {
VeryLongFieldName string
}

rows := []row{
{VeryLongFieldName: "value1"},
}

cols, data, err := MakeColumnsAndRows(rows, nil)
require.NoError(t, err)

require.Len(t, cols, 1)
require.Equal(t, "Very Long Field Name", cols[0])
require.Equal(t, [][]string{{"value1"}}, data)
}

func TestMakeColumnsAndRowsEmptySlice(t *testing.T) {
type row struct {
Name string
ResourceID string
}

var rows []row

cols, data, err := MakeColumnsAndRows(rows, nil)
require.NoError(t, err)

require.Equal(t, []string{"Name", "Resource ID"}, cols)
require.Empty(t, data)
}

func TestMakeColumnsAndRowsNonStructType(t *testing.T) {
rows := []int{1, 2, 3}

cols, data, err := MakeColumnsAndRows(rows, nil)
require.Error(t, err)
require.Nil(t, cols)
require.Nil(t, data)

var traceErr trace.Error
ok := errors.As(err, &traceErr)
require.True(t, ok)

require.Contains(t, err.Error(), "only slices of struct are supported")
}

func TestMakeColumnsAndRowsIncludeColumnsUnknown(t *testing.T) {
type row struct {
Name string
}

rows := []row{
{Name: "n1"},
}

cols, data, err := MakeColumnsAndRows(rows, []string{"Unknown"})
require.NoError(t, err)

require.Empty(t, cols)
require.Equal(t, [][]string{{}}, data)
require.Len(t, data, 1)
}
11 changes: 9 additions & 2 deletions lib/utils/jsontools.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,7 @@ func FastMarshalIndent(v any, prefix, indent string) ([]byte, error) {
// WriteJSONArray marshals values as a JSON array.
func WriteJSONArray[T any](w io.Writer, values []T) error {
if len(values) == 0 {
_, err := w.Write([]byte("[]"))
return err
values = []T{}
}
return WriteJSON(w, values)
}
Expand Down Expand Up @@ -163,6 +162,14 @@ func StreamJSONArray[T any](items stream.Stream[T], out io.Writer, indent bool)
return trace.NewAggregate(items.Done(), stream.Flush())
}

// WriteYAMLArray marshals values as a YAML array.
func WriteYAMLArray[T any](w io.Writer, values []T) error {
if len(values) == 0 {
values = []T{}
}
return writeYAML(w, values)
}

const yamlDocDelimiter = "---"

// WriteYAML detects whether value is a list
Expand Down
4 changes: 2 additions & 2 deletions tool/tctl/common/workload_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ func TestWorkloadIdentityRevocation(t *testing.T) {
},
)
require.NoError(t, err)
require.Equal(t, "[]", buf.String())
require.Equal(t, "[]\n", buf.String())
})

t.Run("workload-identity revocations add", func(t *testing.T) {
Expand Down Expand Up @@ -307,6 +307,6 @@ func TestWorkloadIdentityRevocation(t *testing.T) {
},
)
require.NoError(t, err)
require.Equal(t, "[]", buf.String())
require.Equal(t, "[]\n", buf.String())
})
}
Loading
Loading