Skip to content
Open
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
41 changes: 41 additions & 0 deletions internal/cli/gen_support_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"unicode/utf8"
Expand Down Expand Up @@ -325,6 +326,46 @@ func TestMemberNotifyDryRunPrintsWholeEmail(t *testing.T) {
}
}

// TestMemberNotifyDryRunTableShowsRecipients pins the default table output of a
// notify dry run: every recipient outcome is listed next to the email, and the
// multi-line email body stays on its own row instead of spilling onto lines
// that read as further fields.
func TestMemberNotifyDryRunTableShowsRecipients(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = map[string]any{
"recipients": []any{
map[string]any{"person_id": 5068740052131, "status": "accepted"},
map[string]any{"person_id": 5068740052132, "status": "skipped", "reason": "no_email"},
},
"html": "<html>\n <body>\n <p>" + strings.Repeat("report line ", 5000) + "</p>\n </body>\n</html>",
}

out, _, err := execCommandSplit("member", "notify",
"--subject", "Daily report", "--html", "<p>report</p>", "--dry-run")
if err != nil {
t.Fatalf("execCommandSplit: %v", err)
}

var got [][]string
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
field, value, _ := strings.Cut(line, " ")
got = append(got, []string{field, strings.TrimSpace(value)})
}
want := [][]string{
{"FIELD", "VALUE"},
{"HTML", "<html> <body> <p>report line report line report line report line report line ..."},
{"RECIPIENTS[0].PERSON_ID", "5068740052131"},
{"RECIPIENTS[0].STATUS", "accepted"},
{"RECIPIENTS[1].PERSON_ID", "5068740052132"},
{"RECIPIENTS[1].REASON", "no_email"},
{"RECIPIENTS[1].STATUS", "skipped"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("dry-run table rows = %q\nwant %q\n---\n%s", got, want, out)
}
}

// TestPrintGenericResultCompleteEnvelopeUnmarked guards the marker's negative
// case: a page that fits carries no truncated/emitted_rows keys — the marker
// means "this page was reduced", not "this command supports reduction".
Expand Down
105 changes: 82 additions & 23 deletions internal/cli/generic_table.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package cli

import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -45,7 +48,8 @@ type genKV struct {
// - a paginated list envelope ({Items:[...], Total, ...}) or a top-level row
// array prints as an aligned table (columns from displayColumns, else a
// reflective heuristic);
// - a single object prints as a vertical key/value table;
// - a single object prints as a vertical key/value table of every value it
// carries, nested ones included;
// - anything we can't model falls back to indented JSON, so output is never
// empty.
func renderGenericTable(ctx *RunContext, data any) error {
Expand All @@ -59,7 +63,9 @@ func renderGenericTable(ctx *RunContext, data any) error {

switch v.Kind() {
case reflect.Slice:
return renderRowTable(ctx, v, v.Len())
if isRowSlice(v.Type()) {
return renderRowTable(ctx, v, v.Len())
}
case reflect.Struct:
if rows, total, ok := listEnvelope(v); ok {
return renderRowTable(ctx, rows, total)
Expand All @@ -68,9 +74,8 @@ func renderGenericTable(ctx *RunContext, data any) error {
return err
}
return renderMcpPerUserOAuthNotice(ctx, v)
default:
return jsonFallback(ctx, data)
}
return jsonFallback(ctx, data)
}

// listEnvelope reports whether struct v is a paginated list envelope: exactly
Expand Down Expand Up @@ -207,23 +212,12 @@ func heuristicColumns(rowType reflect.Type) []output.Column {
return cols
}

// renderVertical prints a single object as a two-column FIELD/VALUE table,
// showing scalar fields with a non-empty value. Nested objects/arrays are
// omitted (json/toon carries the full shape for machines).
// renderVertical prints a single object as a two-column FIELD/VALUE table with
// one row per non-empty scalar value. A value nested in an object, map or array
// is named by its path — OWNER.EMAIL, LABELS.env, RECIPIENTS[0].STATUS — so
// nested fields are listed alongside the top-level ones instead of dropped.
func renderVertical(ctx *RunContext, v reflect.Value) error {
t := v.Type()
rows := make([]genKV, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" || !isScalarType(f.Type) {
continue
}
s := scalarString(v.Field(i))
if s == "" || s == "-" {
continue
}
rows = append(rows, genKV{Field: headerFromField(f), Value: s})
}
rows := appendLeafRows(nil, "", v)
if len(rows) == 0 {
return jsonFallback(ctx, v.Interface())
}
Expand All @@ -234,6 +228,50 @@ func renderVertical(ctx *RunContext, v reflect.Value) error {
return ctx.Printer.Print(rows, cols)
}

// appendLeafRows appends a row for every non-empty scalar (or timestamp)
// reachable from v, naming it by its path under prefix: struct fields by
// headerFromField, map entries by key (sorted), array elements by index.
func appendLeafRows(rows []genKV, prefix string, v reflect.Value) []genKV {
for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
if v.IsNil() {
return rows
}
v = v.Elem()
}
if isScalarType(v.Type()) {
if s := scalarString(v); s != "" && s != "-" {
rows = append(rows, genKV{Field: prefix, Value: s})
}
return rows
}
switch v.Kind() {
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if f := v.Type().Field(i); f.PkgPath == "" {
rows = appendLeafRows(rows, joinFieldPath(prefix, headerFromField(f)), v.Field(i))
}
}
case reflect.Map:
keys := v.MapKeys()
sort.Slice(keys, func(i, j int) bool { return fmt.Sprint(keys[i]) < fmt.Sprint(keys[j]) })
for _, k := range keys {
rows = appendLeafRows(rows, joinFieldPath(prefix, fmt.Sprint(k)), v.MapIndex(k))
}
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
rows = appendLeafRows(rows, fmt.Sprintf("%s[%d]", prefix, i), v.Index(i))
}
}
return rows
}

func joinFieldPath(prefix, name string) string {
if prefix == "" {
return name
}
return prefix + "." + name
}

func renderMcpPerUserOAuthNotice(ctx *RunContext, v reflect.Value) error {
if !isMcpPerUserOAuth(v) {
return nil
Expand Down Expand Up @@ -299,7 +337,9 @@ func fieldValue(item any, goField string) any {
}

// scalarString formats a scalar (or timestamp) reflect value. Non-scalars yield
// "" — the generic table never renders nested objects/arrays.
// "" — a table cell holds one scalar; appendLeafRows reaches nested ones. Byte
// slices print as --json shows them: a json.RawMessage as its compact JSON
// text, any other []byte as base64.
func scalarString(fv reflect.Value) string {
for fv.Kind() == reflect.Pointer {
if fv.IsNil() {
Expand All @@ -323,20 +363,39 @@ func scalarString(fv reflect.Value) string {
return strconv.FormatUint(fv.Uint(), 10)
case reflect.Float32, reflect.Float64:
return strconv.FormatFloat(fv.Float(), 'f', -1, 64)
case reflect.Slice:
if !isByteSlice(fv.Type()) {
return ""
}
if fv.Type() != rawMessageType {
return base64.StdEncoding.EncodeToString(fv.Bytes())
}
var compact bytes.Buffer
if err := json.Compact(&compact, fv.Bytes()); err != nil {
return string(fv.Bytes())
}
return compact.String()
default:
return ""
}
}

var rawMessageType = reflect.TypeOf(json.RawMessage(nil))

func isByteSlice(t reflect.Type) bool {
return t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8
}

// isScalarType reports whether t is renderable as a single table cell: a string,
// number, bool, or a timestamp (instant) type.
// number, bool, timestamp (instant) or byte slice ([]byte, json.RawMessage) —
// the byte slices are one encoded value, not an array of numbers.
func isScalarType(t reflect.Type) bool {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
// Timestamp/TimestampMilli satisfy instantLike with value receivers, so the
// deref'd (non-pointer) type implements it directly.
if t.Implements(instantLikeType) {
if t.Implements(instantLikeType) || isByteSlice(t) {
return true
}
switch t.Kind() {
Expand Down
93 changes: 93 additions & 0 deletions internal/cli/generic_table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cli

import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
Expand Down Expand Up @@ -144,6 +146,97 @@ func TestRenderGenericTable_DetailVertical(t *testing.T) {
}
}

type detailOwner struct {
Email string `json:"email"`
}

type detailRecipient struct {
PersonID int64 `json:"person_id"`
Reason string `json:"reason"`
Status string `json:"status"`
}

// nestedDetail is a single-object response carrying the non-scalar shapes SDK
// responses use: a nested object, an array of objects, an array of scalars, a
// map, a free-form (any) value, and an unset nested pointer — plus the byte
// slices that are single encoded values rather than arrays (raw JSON, bytes).
type nestedDetail struct {
Name string `json:"name"`
Owner detailOwner `json:"owner"`
Recipients []detailRecipient `json:"recipients"`
Tags []string `json:"tags"`
Labels map[string]string `json:"labels"`
Payload any `json:"payload"`
Backup *detailOwner `json:"backup"`
Result json.RawMessage `json:"result"`
Digest []byte `json:"digest"`
}

// TestRenderGenericTable_DetailShowsNestedFields pins that a single object's
// table output carries every non-empty value, not only its top-level scalars:
// nested fields print under their path, so nothing json/toon would show is
// missing from the table.
func TestRenderGenericTable_DetailShowsNestedFields(t *testing.T) {
var buf bytes.Buffer
resp := &nestedDetail{
Name: "db-rollback",
Owner: detailOwner{Email: "sre@example.com"},
Recipients: []detailRecipient{
{PersonID: 1, Status: "accepted"},
{PersonID: 2, Status: "skipped", Reason: "no_email"},
},
Tags: []string{"prod", "db"},
Labels: map[string]string{"service": "api", "env": "prod"},
Payload: map[string]any{"window": []any{"22:00", "23:00"}},
Result: json.RawMessage(`{ "rows": [1, 2] }`),
Digest: []byte("hi"),
}
if err := renderGenericTable(tableCtx(&buf), resp); err != nil {
t.Fatalf("render: %v", err)
}

rows := [][2]string{
{"FIELD", "VALUE"},
{"NAME", "db-rollback"},
{"OWNER.EMAIL", "sre@example.com"},
{"RECIPIENTS[0].PERSON_ID", "1"},
{"RECIPIENTS[0].STATUS", "accepted"},
{"RECIPIENTS[1].PERSON_ID", "2"},
{"RECIPIENTS[1].REASON", "no_email"},
{"RECIPIENTS[1].STATUS", "skipped"},
{"TAGS[0]", "prod"},
{"TAGS[1]", "db"},
{"LABELS.env", "prod"},
{"LABELS.service", "api"},
{"PAYLOAD.window[0]", "22:00"},
{"PAYLOAD.window[1]", "23:00"},
// Byte slices print as --json encodes them, not one row per byte.
{"RESULT", `{"rows":[1,2]}`},
{"DIGEST", "aGk="},
}
var want strings.Builder
for _, r := range rows {
fmt.Fprintf(&want, "%-25s%s\n", r[0], r[1])
}
if got := buf.String(); got != want.String() {
t.Errorf("detail output mismatch\n got:\n%s\nwant:\n%s", got, want.String())
}
}

// TestRenderGenericTable_TopLevelScalarArray pins that a bare array of scalars
// (e.g. a list of names) is printed, not treated as a row table it cannot be.
func TestRenderGenericTable_TopLevelScalarArray(t *testing.T) {
var buf bytes.Buffer
if err := renderGenericTable(tableCtx(&buf), flashduty.SLSLogstoresResponse{"app-log", "audit-log"}); err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{"app-log", "audit-log"} {
if !strings.Contains(buf.String(), want) {
t.Errorf("output missing %q\n---\n%s", want, buf.String())
}
}
}

func TestRenderGenericTable_McpServerItemRendersDetail(t *testing.T) {
var buf bytes.Buffer
item := &flashduty.McpServerItem{
Expand Down
5 changes: 4 additions & 1 deletion internal/output/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ func (p *TablePrinter) Print(data any, columns []Column) error {
for r, item := range items {
vals := make([]string, len(columns))
for i, col := range columns {
v := col.Field(item)
// A cell is one line: fold line breaks and indentation so a
// multi-line value neither spills onto lines that read as further
// rows nor spends the column width on whitespace.
v := strings.Join(strings.Fields(col.Field(item)), " ")
if !p.noTrunc && col.MaxWidth > 0 {
v = Truncate(v, col.MaxWidth)
}
Expand Down
27 changes: 27 additions & 0 deletions internal/output/table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,33 @@ func TestTablePrinter_NoTruncSkipsTruncation(t *testing.T) {
}
}

// A cell is one line: a value's line breaks and indentation fold to single
// spaces, so a multi-line value (an HTML body, a prompt) neither spills onto
// lines that read as further rows nor spends the column width on indentation.
func TestTablePrinter_MultilineValueStaysOnItsRow(t *testing.T) {
value := "<html>\n <body>\r\n\t<p>Rollback at 22:00</p>\n </body>\n</html>"
for _, tt := range []struct {
noTrunc bool
want string
}{
{noTrunc: false, want: "<html> <body> <p>Rol..."},
{noTrunc: true, want: "<html> <body> <p>Rollback at 22:00</p> </body> </html>"},
} {
var buf bytes.Buffer
p := &TablePrinter{w: &buf, noTrunc: tt.noTrunc}
if err := p.Print([]testRow{{Name: value, Value: "next"}}, []Column{nameCol(23), valueCol(0)}); err != nil {
t.Fatalf("Print returned error: %v", err)
}
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("noTrunc=%v: want header + 1 row, got %d lines: %q", tt.noTrunc, len(lines), buf.String())
}
if got := strings.TrimSuffix(lines[1], " next"); strings.TrimRight(got, " ") != tt.want {
t.Errorf("noTrunc=%v: cell = %q, want %q", tt.noTrunc, got, tt.want)
}
}
}

func TestTablePrinter_EmptyData(t *testing.T) {
// 31
var buf bytes.Buffer
Expand Down