diff --git a/temporalcloudcli/commands.go b/temporalcloudcli/commands.go index 806925b..3983605 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 @@ -458,13 +461,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, timeoutCancel *context.CancelFunc) error { // Set this command as the root cctx.RootCommand = c @@ -531,6 +540,7 @@ func (c *CloudCommand) preRun(cctx *CommandContext, timeoutCancel *context.Cance 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..d315606 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,10 @@ 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{}, ) } 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..b7a3cc0 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 @@ -37,6 +38,24 @@ type Printer struct { 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 strings.TrimPrefix(s, prefix), true + } + return "UNKNOWN", true + } + return "", false + }) } // Ignored during JSON output @@ -50,12 +69,8 @@ func (p *Printer) Print(s ...string) { // Ignored during JSON output func (p *Printer) Println(s ...string) { - p.Print(append(append([]string{}, s...), "\n")...) -} - -// Ignored during JSON output -func (p *Printer) Printlnf(s string, v ...any) { - p.Println(fmt.Sprintf(s, v...)) + p.Print(s...) + p.Print("\n") } // When called for JSON with indent, this will create an initial bracket and @@ -94,6 +109,11 @@ func (p *Printer) EndList() { } } +// Ignored during JSON output +func (p *Printer) Printlnf(s string, v ...any) { + p.Println(fmt.Sprintf(s, v...)) +} + type StructuredOptions struct { // Derived if not present. Ignored for JSON printing. Fields []string @@ -223,7 +243,6 @@ func (p *Printer) printJSON(v any, options StructuredOptions) error { } } - // Print JSON shorthandPayloads := p.JSONPayloadShorthand if options.OverrideJSONPayloadShorthand != nil { shorthandPayloads = *options.OverrideJSONPayloadShorthand @@ -414,7 +433,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 +482,9 @@ func (p *Printer) textVal(v any) string { return sb.String() } } + if v == nil { + return "" + } return fmt.Sprintf("%v", v) } @@ -524,6 +561,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 +642,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 +662,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 +714,195 @@ 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) + } + + cols = adjustColsToOptions(cols, StructuredOptions{Fields: options.Fields, Table: &tableOptions}) + 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..d99412d 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,21 +85,174 @@ 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_Table(t *testing.T) { + type TableStruct struct { + Name string + Age int + Active bool + } + + var buf bytes.Buffer + p := Printer{Output: &buf} + + // 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") +} + +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_JSONList(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.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{})) + 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, `[ { @@ -116,17 +266,17 @@ func TestPrinter_JSONList(t *testing.T) { // Without indentation buf.Reset() - p = printer.Printer{Output: &buf, JSON: true} + p = 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{})) + 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.Printer{Output: &buf, JSON: true, JSONIndent: " "} + p = Printer{Output: &buf, JSON: true, JSONIndent: " "} p.StartList() p.Println("should not print") p.EndList() @@ -134,9 +284,660 @@ func TestPrinter_JSONList(t *testing.T) { // Empty without indentation buf.Reset() - p = printer.Printer{Output: &buf, JSON: true} + 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} + + 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.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} + + 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() + require.NoError(t, p.PrintDiff(a, b, DiffOptions{NoColor: true, Verbose: true})) + require.NotEmpty(t, buf.String()) + + // Test non-verbose mode + buf.Reset() + 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) +}