From ceb7e4068cc4b2d85bd9cd219722bc8482ecafb1 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:23:23 -0800 Subject: [PATCH 1/5] A cloud resource printer --- temporalcloudcli/commands.go | 16 +- temporalcloudcli/commands.namespace.go | 14 +- .../commands.namespace.lifecycle.go | 2 +- temporalcloudcli/internal/printer/printer.go | 374 +++++--- .../internal/printer/printer_test.go | 856 ++++++++++++++++-- 5 files changed, 1086 insertions(+), 176 deletions(-) diff --git a/temporalcloudcli/commands.go b/temporalcloudcli/commands.go index 14bbd03..2471e81 100644 --- a/temporalcloudcli/commands.go +++ b/temporalcloudcli/commands.go @@ -18,15 +18,18 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" "go.temporal.io/api/common/v1" "go.temporal.io/api/failure/v1" "go.temporal.io/api/temporalproto" + "go.temporal.io/cloud-sdk/api/operation/v1" + "go.temporal.io/cloud-sdk/api/resource/v1" "go.temporal.io/cloud-sdk/cloudclient" "go.temporal.io/sdk/contrib/envconfig" "golang.org/x/term" "google.golang.org/grpc" "google.golang.org/protobuf/proto" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" ) // Version is the value put as the default command version. This is often @@ -452,13 +455,19 @@ var buildInfo string func VersionString() string { // To add build-time information to the version string, use // go build -ldflags "-X github.com/temporalio/cloud-cli/temporalcloudcli.buildInfo=" - var bi = buildInfo + bi := buildInfo if bi != "" { bi = fmt.Sprintf(", %s", bi) } return fmt.Sprintf("%s%s", Version, bi) } +func registerKnownPrinterEnumToStringConverters(p *printer.Printer) { + // Register any enum converters for known types here. + printer.RegisterEnumToStringConverter[resource.ResourceState](p, "RESOURCE_STATE_", resource.ResourceState_name) + printer.RegisterEnumToStringConverter[operation.AsyncOperation_State](p, "STATE_", operation.AsyncOperation_State_name) +} + func (c *CloudCommand) preRun(cctx *CommandContext) error { // Set this command as the root cctx.RootCommand = c @@ -497,7 +506,7 @@ func (c *CloudCommand) preRun(cctx *CommandContext) error { } // Configure printer if not already on context - cctx.JSONOutput = c.Output.Value == "json" || c.Output.Value == "jsonl" + cctx.JSONOutput = c.Output.Value == "json" // Only indent JSON if not jsonl var jsonIndent string if c.Output.Value == "json" { @@ -525,6 +534,7 @@ func (c *CloudCommand) preRun(cctx *CommandContext) error { default: return fmt.Errorf("invalid time format %q", c.TimeFormat.Value) } + registerKnownPrinterEnumToStringConverters(cctx.Printer) } cctx.JSONShorthandPayloads = !c.NoJsonShorthandPayloads if c.CommandTimeout.Duration() > 0 { diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index 419a5c2..d968a2e 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -25,7 +25,7 @@ func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, _ []string) error { if c.Spec { return cctx.Printer.PrintStructured(n.Spec, printer.StructuredOptions{}) } - return cctx.Printer.PrintStructured(n, printer.StructuredOptions{}) + return cctx.Printer.PrintResource(n, printer.PrintResourceOptions{}) } func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, _ []string) error { @@ -271,7 +271,7 @@ func (c *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error return err } - return cctx.Printer.PrintStructured( + return cctx.Printer.PrintResourceList( struct { Namespaces []*namespace.Namespace NextPageToken string @@ -279,6 +279,14 @@ func (c *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error Namespaces: namespaces, NextPageToken: nextPageToken, }, - printer.StructuredOptions{}, + printer.PrintResourceOptions{ + Fields: []string{"Namespace", "State", "CreatedTime"}, + SpecFields: []string{"Regions"}, + }, + printer.TableOptions{ + FieldWidths: map[string]int{ + "Namespace": 200, + }, + }, ) } diff --git a/temporalcloudcli/commands.namespace.lifecycle.go b/temporalcloudcli/commands.namespace.lifecycle.go index 9e22a48..7dc28da 100644 --- a/temporalcloudcli/commands.namespace.lifecycle.go +++ b/temporalcloudcli/commands.namespace.lifecycle.go @@ -35,7 +35,7 @@ func (c *CloudNamespaceLifecycleGetCommand) run(cctx *CommandContext, _ []string EnableDeleteProtection: enableDeleteProtection, } - return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + return cctx.Printer.PrintResource(result, printer.PrintResourceOptions{}) } func (c *CloudNamespaceLifecycleSetCommand) run(cctx *CommandContext, _ []string) error { diff --git a/temporalcloudcli/internal/printer/printer.go b/temporalcloudcli/internal/printer/printer.go index f32967d..217cfad 100644 --- a/temporalcloudcli/internal/printer/printer.go +++ b/temporalcloudcli/internal/printer/printer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "maps" "reflect" "slices" "strconv" @@ -21,13 +22,13 @@ import ( const NonJSONIndent = " " -type Colorer func(string, ...interface{}) string +type Colorer func(string, ...any) string type Printer struct { // Must always be present Output io.Writer JSON bool - // This is unset/empty in JSONL mode + // Only used for JSON, defaults to no indent JSONIndent string JSONPayloadShorthand bool // Only used for non-JSON, defaults to RFC3339 @@ -35,8 +36,23 @@ type Printer struct { // Only used for non-JSON, defaults to color.Magenta TableHeaderColorer Colorer - listMode bool - listModeFirstJSON bool // True until first JSON printed + registeredTextConverters []func(t any) (string, bool) +} + +func (p *Printer) RegisterTextConverter(converter func(any) (string, bool)) { + p.registeredTextConverters = append(p.registeredTextConverters, converter) +} + +func RegisterEnumToStringConverter[T ~int32](p *Printer, prefix string, resourceNameMap map[int32]string) { + p.RegisterTextConverter(func(r any) (string, bool) { + if v, ok := r.(T); ok { + if s, ok := resourceNameMap[int32(v)]; ok { + return s[len(prefix):], true + } + return "UNKNOWN", true + } + return "", false + }) } // Ignored during JSON output @@ -50,7 +66,8 @@ func (p *Printer) Print(s ...string) { // Ignored during JSON output func (p *Printer) Println(s ...string) { - p.Print(append(append([]string{}, s...), "\n")...) + p.Print(s...) + p.Print("\n") } // Ignored during JSON output @@ -58,42 +75,6 @@ func (p *Printer) Printlnf(s string, v ...any) { p.Println(fmt.Sprintf(s, v...)) } -// When called for JSON with indent, this will create an initial bracket and -// make sure all [Printer.PrintStructured] calls get commas properly to appear -// as a list (but the indention and multiline posture of the JSON remains). When -// called for JSON without indent, this will make sure all -// [Printer.PrintStructured] is on its own line (i.e. JSONL mode). When called -// for non-JSON, this is a no-op. -// -// [Printer.EndList] must be called at the end. If this is called twice it will -// panic. This and the end call are not safe for concurrent use. -func (p *Printer) StartList() { - if p.listMode { - panic("already in list mode") - } - p.listMode, p.listModeFirstJSON = true, true - // Write initial bracket when non-jsonl - if p.JSON && p.JSONIndent != "" { - // Don't need newline, we count on initial object to do that - p.Output.Write([]byte("[")) - } -} - -// Must be called after [Printer.StartList] or will panic. See Godoc on that -// function for more details. -func (p *Printer) EndList() { - if !p.listMode { - panic("not in list mode") - } - p.listMode, p.listModeFirstJSON = false, false - // Write ending bracket when non-jsonl - if p.JSON && p.JSONIndent != "" { - // We prepend a newline because non-jsonl list mode doesn't do so after each - // line to help with commas - p.Output.Write([]byte("\n]\n")) - } -} - type StructuredOptions struct { // Derived if not present. Ignored for JSON printing. Fields []string @@ -152,44 +133,6 @@ func (p *Printer) PrintStructured(v any, options StructuredOptions) error { return nil } -type PrintStructuredIter interface { - // Nil when done - Next() (any, error) -} - -// Fields must be present for table -func (p *Printer) PrintStructuredTableIter( - typ reflect.Type, - iter PrintStructuredIter, - options StructuredOptions, -) error { - if options.Table == nil { - return fmt.Errorf("must be table") - } - cols := options.toPredefinedCols() - if len(cols) == 0 { - var err error - if cols, err = deriveCols(typ); err != nil { - return fmt.Errorf("unable to derive columns: %w", err) - } - } - cols = adjustColsToOptions(cols, options) - // We're intentionally not calculating field lengths and only accepting them - // since this is streaming - p.printHeader(cols) - for { - v, err := iter.Next() - if v == nil || err != nil { - return err - } - row, err := p.tableRowData(cols, v) - if err != nil { - return err - } - p.printRow(cols, row) - } -} - func (p *Printer) write(b []byte) { if _, err := p.Output.Write(b); err != nil { panic(err) @@ -207,23 +150,6 @@ func (p *Printer) writef(s string, v ...any) { } func (p *Printer) printJSON(v any, options StructuredOptions) error { - // Before printing, if we're in non-jsonl list mode, we must append a comma - // and a newline if we're not the first JSON seen. - nonJSONLListMode := p.listMode && p.JSON && p.JSONIndent != "" - if nonJSONLListMode { - var prepend string - if p.listModeFirstJSON { - p.listModeFirstJSON = false - prepend = "\n" - } else { - prepend = ",\n" - } - if _, err := p.Output.Write([]byte(prepend)); err != nil { - return err - } - } - - // Print JSON shorthandPayloads := p.JSONPayloadShorthand if options.OverrideJSONPayloadShorthand != nil { shorthandPayloads = *options.OverrideJSONPayloadShorthand @@ -233,12 +159,8 @@ func (p *Printer) printJSON(v any, options StructuredOptions) error { } else if _, err := p.Output.Write(b); err != nil { return err } - - // Do not print a newline if in non-jsonl list mode - if !nonJSONLListMode { - if _, err := p.Output.Write([]byte("\n")); err != nil { - return err - } + if _, err := p.Output.Write([]byte("\n")); err != nil { + return err } return nil } @@ -414,7 +336,22 @@ func (p *Printer) printCard(cols []*col, row map[string]colVal) { var jsonMarshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() +func (p *Printer) applyConverters(v any) (string, bool) { + for _, converter := range p.registeredTextConverters { + if ifv, ok := converter(v); ok { + return ifv, true + } + } + return "", false +} + func (p *Printer) textVal(v any) string { + // Check converters first + if ifv, ok := p.applyConverters(v); ok { + return ifv + } + + // Handle some special types that would be too verbose or not helpful to print as JSON. We check these after converters so that users can override them if they want. if ref := reflect.Indirect(reflect.ValueOf(v)); ref.IsValid() { if ref.Type() == reflect.TypeOf(time.Time{}) { if ref.IsZero() { @@ -448,6 +385,9 @@ func (p *Printer) textVal(v any) string { return sb.String() } } + if v == nil { + return "" + } return fmt.Sprintf("%v", v) } @@ -490,21 +430,6 @@ func (p *Printer) tableData(predefinedCols []*col, v any) (cols []*col, rows []m return } -func (p *Printer) tableRowData(cols []*col, v any) (map[string]colVal, error) { - colValGetter, err := colValGetterForType(reflect.TypeOf(v)) - if err != nil { - return nil, err - } - row := make(map[string]colVal, len(cols)) - itemVal := reflect.ValueOf(v) - for _, col := range cols { - colVal := colVal{val: colValGetter(col, itemVal)} - colVal.text = p.textVal(colVal.val) - row[col.name] = colVal - } - return row, nil -} - func colValGetterForType(t reflect.Type) (func(col *col, v reflect.Value) any, error) { switch t.Kind() { case reflect.Map: @@ -524,6 +449,9 @@ func colValGetterForType(t reflect.Type) (func(col *col, v reflect.Value) any, e return nil, fmt.Errorf("expected map, struct, or pointer to struct, got: %v", t) } return func(col *col, v reflect.Value) any { + if v.IsNil() { + return nil + } return v.Elem().FieldByName(col.name).Interface() }, nil default: @@ -602,7 +530,7 @@ func deriveColFromField(f reflect.StructField) *col { } } // Also consider json tags to allow omitting empty cards if the json field would also be omitted - for _, tagPart := range strings.Split(f.Tag.Get("json"), ",") { + for tagPart := range strings.SplitSeq(f.Tag.Get("json"), ",") { switch tagPart { case "omitempty": col.cardOmitEmpty = true @@ -622,6 +550,25 @@ func (p *Printer) PrintDiff(a, b any, options DiffOptions) error { if reflect.TypeOf(a) != reflect.TypeOf(b) { return fmt.Errorf("cannot diff different types: %v vs %v", reflect.TypeOf(a), reflect.TypeOf(b)) } + + // In JSON mode emit a structured {"before": ..., "after": ...} object. + // Each value is marshaled individually so proto messages are handled correctly, + // then embedded as RawMessage to preserve field order in the outer object. + if p.JSON { + beforeJSON, err := p.jsonVal(a, "", p.JSONPayloadShorthand) + if err != nil { + return fmt.Errorf("unable to convert before value for diff: %w", err) + } + afterJSON, err := p.jsonVal(b, "", p.JSONPayloadShorthand) + if err != nil { + return fmt.Errorf("unable to convert after value for diff: %w", err) + } + return p.printJSON(struct { + Before json.RawMessage `json:"before"` + After json.RawMessage `json:"after"` + }{Before: beforeJSON, After: afterJSON}, StructuredOptions{}) + } + var atext, btext []byte atext, err := p.jsonVal(a, " ", true) if err != nil { @@ -655,3 +602,194 @@ func (p *Printer) PrintDiff(a, b any, options DiffOptions) error { } return nil } + +type PrintResourceOptions struct { + // Fields is a list of fields to print, if empty all fields are printed. This is ignored for JSON output. + Fields []string + // SpecFields is a list of fields to print from the "Spec" sub-object, if empty all fields are printed. This is ignored for JSON output. + SpecFields []string +} + +func (p *Printer) PrintResource(resource any, options PrintResourceOptions) error { + // For JSON we can just print the whole thing, ignoring the field options + if p.JSON { + return p.PrintStructured(resource, StructuredOptions{}) + } + + // For text we want to print "metadata" fields at the top level, and then "spec" fields below that with an indent. We can achieve this by printing two separate cards. + resourceVal := reflect.ValueOf(resource) + if resourceVal.Kind() == reflect.Pointer { + resourceVal = resourceVal.Elem() + } + if resourceVal.Kind() != reflect.Struct { + return fmt.Errorf("expected struct or pointer to struct for PrintResource, got: %v", resourceVal.Kind()) + } + + // print all top-level fields except "Spec" + cols, row := p.parseFields(resourceVal, options.Fields, []string{"Spec"}, 1) + p.printCard(cols, row) + + // now print "Spec" fields if present + specCols, specRow := p.parseFields(resourceVal.FieldByName("Spec"), options.SpecFields, nil, 2) + if len(specCols) > 0 { + p.writeStr(NonJSONIndent) + p.writeStr("Spec:\n") + p.printCard(specCols, specRow) + } + return nil +} + +func (p *Printer) parseFields( + v reflect.Value, + allowList []string, + excludeList []string, + indent int, +) (cols []*col, row map[string]colVal) { + if !v.IsValid() { + return + } + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return + } + v = v.Elem() + } + cols = make([]*col, 0, v.NumField()) + row = make(map[string]colVal) + for i := 0; i < v.NumField(); i++ { + field := v.Type().Field(i) + if !field.IsExported() { + continue + } + if len(allowList) > 0 && !slices.Contains(allowList, field.Name) { + continue + } + if slices.Contains(excludeList, field.Name) { + continue + } + if isZero(v.Field(i).Interface()) { + continue + } + cols = append(cols, &col{name: field.Name, indentAmount: indent}) + row[field.Name] = colVal{val: v.Field(i).Interface(), text: p.textVal(v.Field(i).Interface())} + } + return +} + +// colsFromType derives column definitions from a struct type, applying allowList +// and excludeList filters. Unlike parseFields it does not inspect values, so +// zero-value fields are always included. This is used for table column headers +// where the set of columns must be stable across all rows. +func colsFromType(t reflect.Type, allowList, excludeList []string, indent int) []*col { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + cols := make([]*col, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + if len(allowList) > 0 && !slices.Contains(allowList, field.Name) { + continue + } + if slices.Contains(excludeList, field.Name) { + continue + } + cols = append(cols, &col{name: field.Name, indentAmount: indent}) + } + return cols +} + +func isZero(v any) bool { + if v == nil { + return true + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return true + } + rv = rv.Elem() + } + return reflect.DeepEqual(rv.Interface(), reflect.Zero(rv.Type()).Interface()) +} + +func (p *Printer) PrintResourceList( + resourceListResp any, + options PrintResourceOptions, + tableOptions TableOptions, +) error { + if p.JSON { + return p.PrintStructured(resourceListResp, StructuredOptions{}) + } + + v := reflect.ValueOf(resourceListResp) + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return nil + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return fmt.Errorf("expected struct or pointer to struct for PrintResourceList, got: %v", v.Kind()) + } + + // Find the slice field (resources) and optional NextPageToken string field. + var resourcesVal reflect.Value + var nextPageToken string + for i := 0; i < v.NumField(); i++ { + field := v.Type().Field(i) + if field.Name == "NextPageToken" { + nextPageTokenVal := v.Field(i) + if nextPageTokenVal.Kind() == reflect.String { + nextPageToken = nextPageTokenVal.String() + } + } + if field.Type.Kind() == reflect.Slice { + if resourcesVal.IsValid() { + return fmt.Errorf("multiple slice fields found in response struct, unable to determine which one is the resources list") + } + resourcesVal = v.Field(i) + } + } + if !resourcesVal.IsValid() || resourcesVal.Kind() != reflect.Slice { + return fmt.Errorf("could not find resources field in response struct") + } + + // Derive columns from the element type so the column set is stable + // regardless of which resources happen to have zero-value fields. + elemType := resourcesVal.Type().Elem() + if elemType.Kind() == reflect.Pointer { + elemType = elemType.Elem() + } + cols := colsFromType(elemType, options.Fields, []string{"Spec"}, 1) + if specField, ok := elemType.FieldByName("Spec"); ok { + cols = append(cols, colsFromType(specField.Type, options.SpecFields, nil, 1)...) + } + + // Build rows; parseFields produces value maps (zero-value fields are absent, + // yielding empty cells — correct for table display). + var rows []map[string]colVal + for i := 0; i < resourcesVal.Len(); i++ { + resourceVal := resourcesVal.Index(i) + if resourceVal.Kind() == reflect.Pointer { + resourceVal = resourceVal.Elem() + } + _, row := p.parseFields(resourceVal, options.Fields, []string{"Spec"}, 1) + _, specRow := p.parseFields(resourceVal.FieldByName("Spec"), options.SpecFields, nil, 1) + maps.Copy(row, specRow) + rows = append(rows, row) + } + + p.calculateUnsetColWidths(cols, rows) + p.printTable(&tableOptions, cols, rows) + + if nextPageToken != "" { + p.writef("Next page token: %s\n", nextPageToken) + } + return nil +} diff --git a/temporalcloudcli/internal/printer/printer_test.go b/temporalcloudcli/internal/printer/printer_test.go index 669d5ac..7a2dda7 100644 --- a/temporalcloudcli/internal/printer/printer_test.go +++ b/temporalcloudcli/internal/printer/printer_test.go @@ -1,20 +1,19 @@ -package printer_test +package printer import ( "bytes" + "encoding/json" "strings" "testing" + "time" "unicode" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" + "go.temporal.io/cloud-sdk/api/operation/v1" + "go.temporal.io/cloud-sdk/api/resource/v1" ) -// TODO(cretz): Test: -// * Text printer specific fields -// * Text printer specific and non-specific fields and all sorts of table options -// * JSON printer - func TestPrinter_Text(t *testing.T) { type MyStruct struct { Foo string @@ -25,7 +24,7 @@ func TestPrinter_Text(t *testing.T) { OmittedCardEmpty string `cli:",cardOmitEmpty"` } var buf bytes.Buffer - p := printer.Printer{Output: &buf} + p := Printer{Output: &buf} // Simple struct non-table no fields set require.NoError(t, p.PrintStructured([]*MyStruct{ { @@ -42,7 +41,7 @@ func TestPrinter_Text(t *testing.T) { Bar: true, ReallyLongField: map[string]int{"": 0}, }, - }, printer.StructuredOptions{})) + }, StructuredOptions{})) // Check require.Equal(t, normalizeMultiline(` Foo 1 @@ -53,8 +52,6 @@ func TestPrinter_Text(t *testing.T) { Foo not-a-number Bar true ReallyLongField map[:0]`), normalizeMultiline(buf.String())) - - // TODO(cretz): Tables and more options } func normalizeMultiline(s string) string { @@ -78,9 +75,9 @@ func TestPrinter_JSON(t *testing.T) { var buf bytes.Buffer // With indentation - p := printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} + p := Printer{Output: &buf, JSON: true, JSONIndent: " "} p.Println("should not print") - require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, StructuredOptions{})) require.Equal(t, `{ "foo": "bar" } @@ -88,55 +85,812 @@ func TestPrinter_JSON(t *testing.T) { // Without indentation buf.Reset() - p = printer.Printer{Output: &buf, JSON: true} + p = Printer{Output: &buf, JSON: true} p.Println("should not print") - require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, StructuredOptions{})) require.Equal(t, "{\"foo\":\"bar\"}\n", buf.String()) } -func TestPrinter_JSONList(t *testing.T) { +func TestPrinter_Table(t *testing.T) { + type TableStruct struct { + Name string + Age int + Active bool + } + var buf bytes.Buffer + p := Printer{Output: &buf} - // With indentation - p := printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} - p.StartList() - p.Println("should not print") - require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) - require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, printer.StructuredOptions{})) - p.EndList() - require.Equal(t, `[ -{ - "foo": "bar" -}, -{ - "baz": "qux" + // Test table with header + require.NoError(t, p.PrintStructured([]TableStruct{ + {Name: "Alice", Age: 30, Active: true}, + {Name: "Bob", Age: 25, Active: false}, + }, StructuredOptions{ + Table: &TableOptions{}, + })) + require.Contains(t, buf.String(), "Name") + require.Contains(t, buf.String(), "Age") + require.Contains(t, buf.String(), "Alice") + require.Contains(t, buf.String(), "Bob") + + // Test table without header + buf.Reset() + require.NoError(t, p.PrintStructured([]TableStruct{ + {Name: "Charlie", Age: 35, Active: true}, + }, StructuredOptions{ + Table: &TableOptions{NoHeader: true}, + })) + require.NotContains(t, buf.String(), "Name") + require.Contains(t, buf.String(), "Charlie") } -] -`, buf.String()) - // Without indentation +func TestPrinter_TableWithOptions(t *testing.T) { + type TableStruct struct { + Short string + Long string + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + // Test with field widths and alignment + require.NoError(t, p.PrintStructured([]TableStruct{ + {Short: "A", Long: "This is a very long string"}, + }, StructuredOptions{ + Table: &TableOptions{ + FieldWidths: map[string]int{"Short": 10, "Long": 15}, + FieldAlign: map[string]Align{"Short": AlignCenter}, + }, + })) + output := buf.String() + require.Contains(t, output, "A") + require.Contains(t, output, "This is a very") +} + +func TestPrinter_SpecificFields(t *testing.T) { + type MyStruct struct { + Field1 string + Field2 string + Field3 string + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + // Test with specific fields + require.NoError(t, p.PrintStructured(MyStruct{ + Field1: "value1", + Field2: "value2", + Field3: "value3", + }, StructuredOptions{ + Fields: []string{"Field1", "Field3"}, + })) + require.Contains(t, buf.String(), "Field1") + require.Contains(t, buf.String(), "Field3") + require.NotContains(t, buf.String(), "Field2") +} + +func TestPrinter_ExcludeFields(t *testing.T) { + type MyStruct struct { + Include string + Exclude string + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + require.NoError(t, p.PrintStructured(MyStruct{ + Include: "show", + Exclude: "hide", + }, StructuredOptions{ + ExcludeFields: []string{"Exclude"}, + })) + require.Contains(t, buf.String(), "Include") + require.NotContains(t, buf.String(), "Exclude") +} + +func TestPrinter_TextVal(t *testing.T) { + var buf bytes.Buffer + p := Printer{Output: &buf} + + type TestStruct struct { + TimeField time.Time + ByteField []byte + SliceField []string + IntField int + FloatField float64 + StructField struct{ X int } + } + + now := time.Now() + testData := TestStruct{ + TimeField: now, + ByteField: []byte{1, 2, 3}, + SliceField: []string{"a", "b", "c"}, + IntField: 42, + FloatField: 3.14, + StructField: struct{ X int }{X: 10}, + } + + require.NoError(t, p.PrintStructured(testData, StructuredOptions{})) + output := buf.String() + + // Check that time is formatted + require.Contains(t, output, now.Format(time.RFC3339)) + // Check that bytes are base64 encoded + require.Contains(t, output, "bytes(") + // Check that slice is formatted with brackets + require.Contains(t, output, "[a, b, c]") + // Check numbers + require.Contains(t, output, "42") + require.Contains(t, output, "3.14") +} + +func TestPrinter_CustomTimeFormat(t *testing.T) { + var buf bytes.Buffer + p := Printer{ + Output: &buf, + FormatTime: func(t time.Time) string { + return "custom-" + t.Format("2006") + }, + } + + type TimeStruct struct { + Created time.Time + } + + testTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + require.NoError(t, p.PrintStructured(TimeStruct{Created: testTime}, StructuredOptions{})) + require.Contains(t, buf.String(), "custom-2023") +} + +func TestPrinter_PrintAndPrintln(t *testing.T) { + var buf bytes.Buffer + p := Printer{Output: &buf} + + p.Print("hello") + p.Print(" ", "world") + p.Println() + p.Printlnf("test %d", 123) + + require.Equal(t, "hello world\ntest 123\n", buf.String()) + + // Verify Print/Println are ignored in JSON mode buf.Reset() - p = printer.Printer{Output: &buf, JSON: true} - p.StartList() - p.Println("should not print") - require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) - require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, printer.StructuredOptions{})) - p.EndList() - require.Equal(t, "{\"foo\":\"bar\"}\n{\"baz\":\"qux\"}\n", buf.String()) + p.JSON = true + p.Print("should not appear") + p.Println("also should not appear") + require.Empty(t, buf.String()) +} + +func TestPrinter_PrintDiff(t *testing.T) { + type DiffStruct struct { + Name string + Value int + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + a := DiffStruct{Name: "old", Value: 10} + b := DiffStruct{Name: "new", Value: 20} - // Empty with indentation + require.NoError(t, p.PrintDiff(a, b, DiffOptions{Verbose: true})) + output := buf.String() + require.Contains(t, output, "old") + require.Contains(t, output, "new") + + // Test with NoColor option buf.Reset() - p = printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} - p.StartList() - p.Println("should not print") - p.EndList() - require.Equal(t, "[\n]\n", buf.String()) + require.NoError(t, p.PrintDiff(a, b, DiffOptions{NoColor: true, Verbose: true})) + require.NotEmpty(t, buf.String()) - // Empty without indentation + // Test non-verbose mode buf.Reset() - p = printer.Printer{Output: &buf, JSON: true} - p.StartList() - p.Println("should not print") - p.EndList() - require.Equal(t, "", buf.String()) + require.NoError(t, p.PrintDiff(a, b, DiffOptions{Verbose: false})) + require.NotEmpty(t, buf.String()) +} + +func TestPrinter_PrintDiff_JSON(t *testing.T) { + type DiffStruct struct { + Name string + Value int + } + + a := DiffStruct{Name: "old", Value: 10} + b := DiffStruct{Name: "new", Value: 20} + + // JSONL mode + var buf bytes.Buffer + p := Printer{Output: &buf, JSON: true} + require.NoError(t, p.PrintDiff(a, b, DiffOptions{})) + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Contains(t, result, "before") + require.Contains(t, result, "after") + require.Contains(t, string(result["before"]), "old") + require.Contains(t, string(result["after"]), "new") + + // Pretty JSON mode + buf.Reset() + p.JSONIndent = " " + require.NoError(t, p.PrintDiff(a, b, DiffOptions{})) + var prettyResult map[string]json.RawMessage + require.NoError(t, json.Unmarshal(buf.Bytes(), &prettyResult)) + require.Contains(t, prettyResult, "before") + require.Contains(t, prettyResult, "after") +} + +func TestPrinter_PrintDiff_DifferentTypes(t *testing.T) { + var buf bytes.Buffer + p := Printer{Output: &buf} + + err := p.PrintDiff("string", 123, DiffOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot diff different types") +} + +func TestPrinter_PrintResource(t *testing.T) { + type ResourceSpec struct { + Replicas int + Image string + } + + type Resource struct { + Name string + Namespace string + Spec ResourceSpec + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + resource := Resource{ + Name: "my-resource", + Namespace: "default", + Spec: ResourceSpec{ + Replicas: 3, + Image: "nginx:latest", + }, + } + + // Test text mode + require.NoError(t, p.PrintResource(resource, PrintResourceOptions{})) + output := buf.String() + require.Contains(t, output, "Name") + require.Contains(t, output, "my-resource") + require.Contains(t, output, "Namespace") + require.Contains(t, output, "default") + require.Contains(t, output, "Spec:") + require.Contains(t, output, "Replicas") + require.Contains(t, output, "3") + require.Contains(t, output, "Image") + require.Contains(t, output, "nginx:latest") + + // Test with specific fields + buf.Reset() + require.NoError(t, p.PrintResource(resource, PrintResourceOptions{ + Fields: []string{"Name"}, + SpecFields: []string{"Replicas"}, + })) + output = buf.String() + require.Contains(t, output, "Name") + require.Contains(t, output, "my-resource") + require.NotContains(t, output, "Namespace") + require.Contains(t, output, "Replicas") + require.NotContains(t, output, "Image") + + // Test JSON mode + buf.Reset() + p.JSON = true + p.JSONIndent = " " + require.NoError(t, p.PrintResource(resource, PrintResourceOptions{})) + var jsonResult map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &jsonResult)) + require.Equal(t, "my-resource", jsonResult["Name"]) +} + +func TestPrinter_PrintResource_Pointer(t *testing.T) { + type ResourceSpec struct { + Count int + } + + type Resource struct { + ID string + Spec *ResourceSpec + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + resource := Resource{ + ID: "test-id", + Spec: &ResourceSpec{Count: 5}, + } + + require.NoError(t, p.PrintResource(&resource, PrintResourceOptions{})) + require.Contains(t, buf.String(), "test-id") + require.Contains(t, buf.String(), "5") +} + +func TestPrinter_PrintResource_NilSpec(t *testing.T) { + type ResourceSpec struct { + Value string + } + + type Resource struct { + Name string + Spec *ResourceSpec + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + resource := Resource{ + Name: "test", + Spec: nil, + } + + require.NoError(t, p.PrintResource(resource, PrintResourceOptions{})) + require.Contains(t, buf.String(), "test") +} + +func TestPrinter_PrintResourceList(t *testing.T) { + type ResourceSpec struct { + Size int + } + + type Resource struct { + Name string + Spec ResourceSpec + } + + type ResourceListResponse struct { + Resources []Resource + NextPageToken string + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + response := ResourceListResponse{ + Resources: []Resource{ + {Name: "resource1", Spec: ResourceSpec{Size: 10}}, + {Name: "resource2", Spec: ResourceSpec{Size: 20}}, + }, + NextPageToken: "next-token-123", + } + + // Test text mode with table + require.NoError(t, p.PrintResourceList(response, PrintResourceOptions{}, TableOptions{})) + output := buf.String() + require.Contains(t, output, "resource1") + require.Contains(t, output, "resource2") + require.Contains(t, output, "Next page token: next-token-123") + + // Test JSON mode + buf.Reset() + p.JSON = true + require.NoError(t, p.PrintResourceList(response, PrintResourceOptions{}, TableOptions{})) + var jsonResult map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &jsonResult)) + require.NotNil(t, jsonResult["Resources"]) +} + +func TestPrinter_PrintResourceList_EmptyToken(t *testing.T) { + type Resource struct { + ID string + } + + type ResourceListResponse struct { + Resources []Resource + NextPageToken string + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + response := ResourceListResponse{ + Resources: []Resource{{ID: "test"}}, + NextPageToken: "", + } + + require.NoError(t, p.PrintResourceList(response, PrintResourceOptions{}, TableOptions{})) + require.NotContains(t, buf.String(), "Next page token") +} + +func TestPrinter_PrintResourceList_Pointer(t *testing.T) { + type Resource struct { + Name string + } + + type ResourceListResponse struct { + Resources []*Resource + NextPageToken string + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + response := &ResourceListResponse{ + Resources: []*Resource{ + {Name: "ptr-resource"}, + }, + } + + require.NoError(t, p.PrintResourceList(response, PrintResourceOptions{}, TableOptions{})) + require.Contains(t, buf.String(), "ptr-resource") +} + +func TestPrinter_PrintResourceList_FirstResourceEmptySpec(t *testing.T) { + type ResourceSpec struct { + Region string + } + + type Resource struct { + Name string + Spec ResourceSpec + } + + type ResourceListResponse struct { + Resources []Resource + NextPageToken string + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + // First resource has an empty Spec; second has a populated one. + // The Region column must still appear for both rows. + response := ResourceListResponse{ + Resources: []Resource{ + {Name: "resource1"}, + {Name: "resource2", Spec: ResourceSpec{Region: "us-east-1"}}, + }, + } + + require.NoError(t, p.PrintResourceList(response, PrintResourceOptions{}, TableOptions{})) + output := buf.String() + require.Contains(t, output, "Region", "spec column header must be present even when first resource has empty spec") + require.Contains(t, output, "us-east-1") +} + +func TestPrinter_StructTags(t *testing.T) { + type TaggedStruct struct { + Normal string + Width10 string `cli:",width=10"` + AlignRight string `cli:",align=right"` + AlignCenter string `cli:",align=center"` + AlignLeft string `cli:",align=left"` + CardOmitEmpty string `cli:",cardOmitEmpty"` + JSONOmitEmpty string `json:",omitempty"` + BothOmitEmpty string `cli:",cardOmitEmpty" json:",omitempty"` + OmittedField string `cli:",omit"` + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + data := TaggedStruct{ + Normal: "normal", + Width10: "w10", + AlignRight: "right", + AlignCenter: "center", + AlignLeft: "left", + // Leave CardOmitEmpty, JSONOmitEmpty, BothOmitEmpty empty to test omission + OmittedField: "should-not-appear", + } + + require.NoError(t, p.PrintStructured(data, StructuredOptions{})) + output := buf.String() + require.Contains(t, output, "normal") + require.Contains(t, output, "right") + require.NotContains(t, output, "should-not-appear") + require.NotContains(t, output, "CardOmitEmpty") + require.NotContains(t, output, "JSONOmitEmpty") + require.NotContains(t, output, "BothOmitEmpty") +} + +func TestPrinter_NumericTypes(t *testing.T) { + type NumericStruct struct { + Int int + Int8 int8 + Int16 int16 + Int32 int32 + Int64 int64 + Uint uint + Uint8 uint8 + Uint16 uint16 + Uint32 uint32 + Uint64 uint64 + Float32 float32 + Float64 float64 + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + data := NumericStruct{ + Int: 1, Int8: 2, Int16: 3, Int32: 4, Int64: 5, + Uint: 6, Uint8: 7, Uint16: 8, Uint32: 9, Uint64: 10, + Float32: 1.5, Float64: 2.5, + } + + require.NoError(t, p.PrintStructured(data, StructuredOptions{ + Table: &TableOptions{}, + })) + output := buf.String() + // Verify field names appear + require.Contains(t, output, "Int") + require.Contains(t, output, "Float32") + require.Contains(t, output, "Float64") + // Verify some numeric values appear + require.Contains(t, output, "1") + require.Contains(t, output, "2.5") + require.Contains(t, output, "1.5") +} + +func TestPrinter_NilElementsInPointerSlice(t *testing.T) { + type Item struct { + Name string + Value int + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + // A nil element mixed with non-nil elements must not panic. + items := []*Item{ + {Name: "first", Value: 1}, + nil, + {Name: "third", Value: 3}, + } + + // Table mode + require.NoError(t, p.PrintStructured(items, StructuredOptions{Table: &TableOptions{}})) + output := buf.String() + require.Contains(t, output, "first") + require.Contains(t, output, "third") + + // Card mode + buf.Reset() + require.NoError(t, p.PrintStructured(items, StructuredOptions{})) + output = buf.String() + require.Contains(t, output, "first") + require.Contains(t, output, "third") +} + +func TestPrinter_MapData(t *testing.T) { + var buf bytes.Buffer + p := Printer{Output: &buf} + + data := []map[string]any{ + {"Name": "Alice", "Age": 30}, + {"Name": "Bob", "Age": 25}, + } + + require.NoError(t, p.PrintStructured(data, StructuredOptions{ + Fields: []string{"Name", "Age"}, + Table: &TableOptions{}, + })) + output := buf.String() + require.Contains(t, output, "Alice") + require.Contains(t, output, "Bob") +} + +func TestPrinter_SingleItem(t *testing.T) { + type Item struct { + Value string + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + // Single item (not in a slice) + require.NoError(t, p.PrintStructured(Item{Value: "single"}, StructuredOptions{})) + require.Contains(t, buf.String(), "single") +} + +func TestPrinter_ExtraIndent(t *testing.T) { + type Simple struct { + Field string + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + require.NoError(t, p.PrintStructured(Simple{Field: "value"}, StructuredOptions{ + NonJSONExtraIndent: 2, + })) + output := buf.String() + // Should have extra indentation (3 levels total: base + extra 2) + require.Contains(t, output, " ") // 6 spaces = 3 indents * 2 spaces +} + +func TestPrinter_ZeroTimeHandling(t *testing.T) { + type TimeStruct struct { + Created time.Time + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + // Zero time should render as empty string + require.NoError(t, p.PrintStructured(TimeStruct{}, StructuredOptions{})) + output := buf.String() + require.Contains(t, output, "Created") + // The value part should be empty or whitespace + lines := strings.Split(output, "\n") + var createdLine string + for _, line := range lines { + if strings.Contains(line, "Created") { + createdLine = line + break + } + } + require.NotEmpty(t, createdLine) + // After "Created", there should be no timestamp value + parts := strings.Fields(createdLine) + require.Len(t, parts, 1) // Just "Created", no value +} + +func TestPrinter_JSONMarshaler(t *testing.T) { + type CustomJSON struct { + Value int + } + + // This type implements json.Marshaler + data := struct { + Custom CustomJSON + Normal string + }{ + Custom: CustomJSON{Value: 42}, + Normal: "text", + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + require.NoError(t, p.PrintStructured(data, StructuredOptions{})) + output := buf.String() + require.Contains(t, output, "42") + require.Contains(t, output, "text") +} + +func TestPrinter_OverrideJSONPayloadShorthand(t *testing.T) { + var buf bytes.Buffer + p := Printer{Output: &buf, JSON: true, JSONPayloadShorthand: true} + + trueVal := true + falseVal := false + + // Test with override to false + require.NoError(t, p.PrintStructured( + map[string]string{"key": "value"}, + StructuredOptions{OverrideJSONPayloadShorthand: &falseVal}, + )) + require.Contains(t, buf.String(), "key") + + // Test with override to true + buf.Reset() + p.JSONPayloadShorthand = false + require.NoError(t, p.PrintStructured( + map[string]string{"key": "value"}, + StructuredOptions{OverrideJSONPayloadShorthand: &trueVal}, + )) + require.Contains(t, buf.String(), "key") +} + +func TestConverters_ResourceState(t *testing.T) { + var buf bytes.Buffer + p := Printer{Output: &buf, JSON: true, JSONPayloadShorthand: true} + RegisterEnumToStringConverter[resource.ResourceState](&p, "RESOURCE_STATE_", resource.ResourceState_name) + + tests := []struct { + name string + input any + output string + ok bool + }{ + { + name: "ACTIVE state", + input: resource.ResourceState_RESOURCE_STATE_ACTIVE, + output: "ACTIVE", + ok: true, + }, + { + name: "DELETED state", + input: resource.ResourceState_RESOURCE_STATE_DELETED, + output: "DELETED", + ok: true, + }, + { + name: "Invalid state returns UNKNOWN", + input: resource.ResourceState(999), + output: "UNKNOWN", + ok: true, + }, + { + name: "Wrong type returns not ok", + input: "not-a-resource-state", + output: "", + ok: false, + }, + { + name: "Integer type returns not ok", + input: 42, + output: "", + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := p.applyConverters(tt.input) + assert.Equal(t, tt.ok, ok, "Converter ok status mismatch") + if tt.ok { + assert.Equal(t, tt.output, result, "Converter output mismatch") + } + }) + } +} + +func TestConverters_AsyncOperationState(t *testing.T) { + var buf bytes.Buffer + p := Printer{Output: &buf, JSON: true, JSONPayloadShorthand: true} + RegisterEnumToStringConverter[operation.AsyncOperation_State](&p, "STATE_", operation.AsyncOperation_State_name) + tests := []struct { + name string + input any + output string + ok bool + }{ + { + name: "PENDING state", + input: operation.AsyncOperation_STATE_PENDING, + output: "PENDING", + ok: true, + }, + { + name: "Invalid async operation state", + input: operation.AsyncOperation_State(888), + output: "UNKNOWN", + ok: true, + }, + { + name: "Wrong type returns not ok", + input: "not-an-operation-state", + output: "", + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := p.applyConverters(tt.input) + assert.Equal(t, tt.ok, ok, "Converter ok status mismatch") + if tt.ok { + assert.Equal(t, tt.output, result, "Converter output mismatch") + } + }) + } +} + +func TestConverters_MultipleConverters(t *testing.T) { + var buf bytes.Buffer + p := Printer{Output: &buf, JSON: true, JSONPayloadShorthand: true} + RegisterEnumToStringConverter[resource.ResourceState](&p, "RESOURCE_STATE_", resource.ResourceState_name) + RegisterEnumToStringConverter[operation.AsyncOperation_State](&p, "STATE_", operation.AsyncOperation_State_name) + + // Test that both converters are registered and work independently + resourceState := resource.ResourceState_RESOURCE_STATE_ACTIVE + asyncState := operation.AsyncOperation_STATE_PENDING + + result1, ok1 := p.applyConverters(resourceState) + require.True(t, ok1) + require.Equal(t, "ACTIVE", result1) + + result2, ok2 := p.applyConverters(asyncState) + require.True(t, ok2) + require.Equal(t, "PENDING", result2) } From cf79c9518de434900b816c36ac737ddc49f22ba3 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:12:54 -0800 Subject: [PATCH 2/5] Adjust table cols width before printing resource list --- temporalcloudcli/commands.namespace.go | 6 +----- temporalcloudcli/internal/printer/printer.go | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index d968a2e..d315606 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -283,10 +283,6 @@ func (c *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error Fields: []string{"Namespace", "State", "CreatedTime"}, SpecFields: []string{"Regions"}, }, - printer.TableOptions{ - FieldWidths: map[string]int{ - "Namespace": 200, - }, - }, + printer.TableOptions{}, ) } diff --git a/temporalcloudcli/internal/printer/printer.go b/temporalcloudcli/internal/printer/printer.go index 217cfad..c705961 100644 --- a/temporalcloudcli/internal/printer/printer.go +++ b/temporalcloudcli/internal/printer/printer.go @@ -785,6 +785,7 @@ func (p *Printer) PrintResourceList( rows = append(rows, row) } + cols = adjustColsToOptions(cols, StructuredOptions{Fields: options.Fields, Table: &tableOptions}) p.calculateUnsetColWidths(cols, rows) p.printTable(&tableOptions, cols, rows) From 6fceb580e18ee96a705192b3bce5c4fe55d84d1e Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:21:31 -0800 Subject: [PATCH 3/5] adding back jsonl --- temporalcloudcli/commands.go | 2 +- temporalcloudcli/internal/printer/printer.go | 116 ++++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/temporalcloudcli/commands.go b/temporalcloudcli/commands.go index 6d3a8ca..3983605 100644 --- a/temporalcloudcli/commands.go +++ b/temporalcloudcli/commands.go @@ -512,7 +512,7 @@ func (c *CloudCommand) preRun(cctx *CommandContext, timeoutCancel *context.Cance } // Configure printer if not already on context - cctx.JSONOutput = c.Output.Value == "json" + cctx.JSONOutput = c.Output.Value == "json" || c.Output.Value == "jsonl" // Only indent JSON if not jsonl var jsonIndent string if c.Output.Value == "json" { diff --git a/temporalcloudcli/internal/printer/printer.go b/temporalcloudcli/internal/printer/printer.go index c705961..810a96d 100644 --- a/temporalcloudcli/internal/printer/printer.go +++ b/temporalcloudcli/internal/printer/printer.go @@ -36,6 +36,9 @@ type Printer struct { // Only used for non-JSON, defaults to color.Magenta TableHeaderColorer Colorer + listMode bool + listModeFirstJSON bool // True until first JSON printed + registeredTextConverters []func(t any) (string, bool) } @@ -70,6 +73,42 @@ func (p *Printer) Println(s ...string) { p.Print("\n") } +// When called for JSON with indent, this will create an initial bracket and +// make sure all [Printer.PrintStructured] calls get commas properly to appear +// as a list (but the indention and multiline posture of the JSON remains). When +// called for JSON without indent, this will make sure all +// [Printer.PrintStructured] is on its own line (i.e. JSONL mode). When called +// for non-JSON, this is a no-op. +// +// [Printer.EndList] must be called at the end. If this is called twice it will +// panic. This and the end call are not safe for concurrent use. +func (p *Printer) StartList() { + if p.listMode { + panic("already in list mode") + } + p.listMode, p.listModeFirstJSON = true, true + // Write initial bracket when non-jsonl + if p.JSON && p.JSONIndent != "" { + // Don't need newline, we count on initial object to do that + p.Output.Write([]byte("[")) + } +} + +// Must be called after [Printer.StartList] or will panic. See Godoc on that +// function for more details. +func (p *Printer) EndList() { + if !p.listMode { + panic("not in list mode") + } + p.listMode, p.listModeFirstJSON = false, false + // Write ending bracket when non-jsonl + if p.JSON && p.JSONIndent != "" { + // We prepend a newline because non-jsonl list mode doesn't do so after each + // line to help with commas + p.Output.Write([]byte("\n]\n")) + } +} + // Ignored during JSON output func (p *Printer) Printlnf(s string, v ...any) { p.Println(fmt.Sprintf(s, v...)) @@ -133,6 +172,44 @@ func (p *Printer) PrintStructured(v any, options StructuredOptions) error { return nil } +type PrintStructuredIter interface { + // Nil when done + Next() (any, error) +} + +// Fields must be present for table +func (p *Printer) PrintStructuredTableIter( + typ reflect.Type, + iter PrintStructuredIter, + options StructuredOptions, +) error { + if options.Table == nil { + return fmt.Errorf("must be table") + } + cols := options.toPredefinedCols() + if len(cols) == 0 { + var err error + if cols, err = deriveCols(typ); err != nil { + return fmt.Errorf("unable to derive columns: %w", err) + } + } + cols = adjustColsToOptions(cols, options) + // We're intentionally not calculating field lengths and only accepting them + // since this is streaming + p.printHeader(cols) + for { + v, err := iter.Next() + if v == nil || err != nil { + return err + } + row, err := p.tableRowData(cols, v) + if err != nil { + return err + } + p.printRow(cols, row) + } +} + func (p *Printer) write(b []byte) { if _, err := p.Output.Write(b); err != nil { panic(err) @@ -150,6 +227,22 @@ func (p *Printer) writef(s string, v ...any) { } func (p *Printer) printJSON(v any, options StructuredOptions) error { + // Before printing, if we're in non-jsonl list mode, we must append a comma + // and a newline if we're not the first JSON seen. + nonJSONLListMode := p.listMode && p.JSON && p.JSONIndent != "" + if nonJSONLListMode { + var prepend string + if p.listModeFirstJSON { + p.listModeFirstJSON = false + prepend = "\n" + } else { + prepend = ",\n" + } + if _, err := p.Output.Write([]byte(prepend)); err != nil { + return err + } + } + shorthandPayloads := p.JSONPayloadShorthand if options.OverrideJSONPayloadShorthand != nil { shorthandPayloads = *options.OverrideJSONPayloadShorthand @@ -159,8 +252,12 @@ func (p *Printer) printJSON(v any, options StructuredOptions) error { } else if _, err := p.Output.Write(b); err != nil { return err } - if _, err := p.Output.Write([]byte("\n")); err != nil { - return err + + // Do not print a newline if in non-jsonl list mode + if !nonJSONLListMode { + if _, err := p.Output.Write([]byte("\n")); err != nil { + return err + } } return nil } @@ -430,6 +527,21 @@ func (p *Printer) tableData(predefinedCols []*col, v any) (cols []*col, rows []m return } +func (p *Printer) tableRowData(cols []*col, v any) (map[string]colVal, error) { + colValGetter, err := colValGetterForType(reflect.TypeOf(v)) + if err != nil { + return nil, err + } + row := make(map[string]colVal, len(cols)) + itemVal := reflect.ValueOf(v) + for _, col := range cols { + colVal := colVal{val: colValGetter(col, itemVal)} + colVal.text = p.textVal(colVal.val) + row[col.name] = colVal + } + return row, nil +} + func colValGetterForType(t reflect.Type) (func(col *col, v reflect.Value) any, error) { switch t.Kind() { case reflect.Map: From 1434122f38613da274ab96d4be63a03f9fe22866 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:23:48 -0800 Subject: [PATCH 4/5] Adding back jsonl tests --- .../internal/printer/printer_test.go | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/temporalcloudcli/internal/printer/printer_test.go b/temporalcloudcli/internal/printer/printer_test.go index 7a2dda7..d99412d 100644 --- a/temporalcloudcli/internal/printer/printer_test.go +++ b/temporalcloudcli/internal/printer/printer_test.go @@ -244,6 +244,53 @@ func TestPrinter_CustomTimeFormat(t *testing.T) { require.Contains(t, buf.String(), "custom-2023") } +func TestPrinter_JSONList(t *testing.T) { + var buf bytes.Buffer + + // With indentation + p := Printer{Output: &buf, JSON: true, JSONIndent: " "} + p.StartList() + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, StructuredOptions{})) + require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, StructuredOptions{})) + p.EndList() + require.Equal(t, `[ +{ + "foo": "bar" +}, +{ + "baz": "qux" +} +] +`, buf.String()) + + // Without indentation + buf.Reset() + p = Printer{Output: &buf, JSON: true} + p.StartList() + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, StructuredOptions{})) + require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, StructuredOptions{})) + p.EndList() + require.Equal(t, "{\"foo\":\"bar\"}\n{\"baz\":\"qux\"}\n", buf.String()) + + // Empty with indentation + buf.Reset() + p = Printer{Output: &buf, JSON: true, JSONIndent: " "} + p.StartList() + p.Println("should not print") + p.EndList() + require.Equal(t, "[\n]\n", buf.String()) + + // Empty without indentation + buf.Reset() + p = Printer{Output: &buf, JSON: true} + p.StartList() + p.Println("should not print") + p.EndList() + require.Equal(t, "", buf.String()) +} + func TestPrinter_PrintAndPrintln(t *testing.T) { var buf bytes.Buffer p := Printer{Output: &buf} From 69489c996dba0ce152dbaf46389414cdef6369ea Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:44:09 -0800 Subject: [PATCH 5/5] address ai code review comments --- temporalcloudcli/internal/printer/printer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalcloudcli/internal/printer/printer.go b/temporalcloudcli/internal/printer/printer.go index 810a96d..b7a3cc0 100644 --- a/temporalcloudcli/internal/printer/printer.go +++ b/temporalcloudcli/internal/printer/printer.go @@ -50,7 +50,7 @@ func RegisterEnumToStringConverter[T ~int32](p *Printer, prefix string, resource p.RegisterTextConverter(func(r any) (string, bool) { if v, ok := r.(T); ok { if s, ok := resourceNameMap[int32(v)]; ok { - return s[len(prefix):], true + return strings.TrimPrefix(s, prefix), true } return "UNKNOWN", true }