From a4b0ba661345dec9e80cf15efee29c1e55939ec5 Mon Sep 17 00:00:00 2001 From: Cheese Date: Thu, 13 Aug 2026 18:34:49 +0800 Subject: [PATCH] fix text output formatting across commands --- README.md | 2 + e2e/cli_test.go | 18 +++ .../db/connectionstring/connectionstring.go | 4 + internal/db/sqlaccess/sqlaccess.go | 17 +++ internal/fs/control.go | 28 +++++ internal/fs/git.go | 50 ++++++++ internal/fs/pack.go | 23 ++++ internal/fs/tokenmgmt/service.go | 75 ++++++++++++ internal/output/command_contract_test.go | 90 ++++++++++++++ internal/output/output.go | 110 +++++++++++++++++- internal/output/output_test.go | 33 ++++++ 11 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 internal/output/command_contract_test.go diff --git a/README.md b/README.md index 2962e57..2b560f0 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,8 @@ ti db create-db-cluster --db-cluster-type starter --db-cluster-name my-distribut - `ti help` - `ti help` +Structured commands output JSON by default. Use `--output text` for command-specific tables or readable key-value output; it never falls back to JSON. When combined with `--query`, scalar lists are printed one item per line and object lists are printed as tables. Commands that intentionally stream raw bytes preserve those bytes and reject `--query`. +
All commands diff --git a/e2e/cli_test.go b/e2e/cli_test.go index cab6db3..fad2833 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -684,6 +684,11 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi list.wantStdoutNotContains("drive9_") list.wantStdoutNotContains("default_file_system_name") list.wantStdoutNotContains("is_default") + textList := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-file-systems", "--output", "text") + textList.wantExitCode(0) + textList.wantStdoutContains("FILE_SYSTEM_ID") + textList.wantStdoutContains("tenant-aws-us-east-1") + textList.wantStdoutNotContains(`"file_system_id"`) westList := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "--region", "aws-us-west-2", "fs", "list-file-systems") westList.wantExitCode(0) westList.wantStdoutContains(`"file_system_id": "tenant-aws-us-west-2"`) @@ -693,6 +698,10 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi describe.wantStdoutContains(`"file_system_id": "tenant-aws-us-west-2"`) describe.wantStdoutContains(`"region_code": "aws-us-west-2"`) describe.wantStdoutNotContains("drive9_") + textDescribe := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "--region", "aws-us-west-2", "fs", "describe-file-system", "--file-system-id", "tenant-aws-us-west-2", "--output", "text") + textDescribe.wantExitCode(0) + textDescribe.wantStdoutContains("File system ID: tenant-aws-us-west-2") + textDescribe.wantStdoutNotContains(`"file_system_id"`) callsBeforeMissingSelectorCommands := len(readFakeDrive9Calls(t, recordPath)) for _, args := range [][]string{ {"fs", "list-files", "--path", "/"}, @@ -1031,6 +1040,11 @@ func TestFSFileSystemTokenLifecycle(t *testing.T) { scoped.wantStdoutContains(`"scope_kind": "fs_scoped"`) scoped.wantStdoutContains(`"prefix": "/workspace"`) scoped.wantStdoutContains(scopedToken) + scopedText := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "generate-file-system-scoped-token", "--file-system-id", "tenant-tokens", "--fs-token", generatedToken, "--subject", "e2e-agent", "--ttl", "1h", "--allow", "/workspace:read,list", "--output", "text") + scopedText.wantExitCode(0) + scopedText.wantStdoutContains("File system ID: tenant-tokens") + scopedText.wantStdoutContains("PREFIX") + scopedText.wantStdoutNotContains(`"scope_kind"`) bearerListEnv := append(append([]string{}, env...), "TI_FS_TOKEN="+generatedToken) bearerListed := runTIWithInput(t, bin, "", bearerListEnv, "--profile", "stage", "fs", "list-file-system-tokens", "--file-system-id", "tenant-tokens") @@ -1048,6 +1062,10 @@ func TestFSFileSystemTokenLifecycle(t *testing.T) { enabled := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "enable-file-system-token", "--file-system-id", "tenant-tokens", "--token-id", "token-e2e") enabled.wantExitCode(0) enabled.wantStdoutContains(`"status": "active"`) + enabledText := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "enable-file-system-token", "--file-system-id", "tenant-tokens", "--token-id", "token-e2e", "--output", "text") + enabledText.wantExitCode(0) + enabledText.wantStdoutContains("Status: active") + enabledText.wantStdoutNotContains(`"status"`) refreshed := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "refresh-file-system-token", "--file-system-id", "tenant-tokens", "--query", "fs_token", "--output", "text") refreshed.wantExitCode(0) diff --git a/internal/db/connectionstring/connectionstring.go b/internal/db/connectionstring/connectionstring.go index 5bd04d0..eec3f9d 100644 --- a/internal/db/connectionstring/connectionstring.go +++ b/internal/db/connectionstring/connectionstring.go @@ -48,6 +48,10 @@ type Result struct { ConnectionString string `json:"connection_string"` } +func (r Result) Human() string { + return r.ConnectionString +} + func Build(input Input) (Result, error) { format := input.Format if format == "" { diff --git a/internal/db/sqlaccess/sqlaccess.go b/internal/db/sqlaccess/sqlaccess.go index de93869..6dc41f9 100644 --- a/internal/db/sqlaccess/sqlaccess.go +++ b/internal/db/sqlaccess/sqlaccess.go @@ -6,6 +6,7 @@ import ( "fmt" "math/big" "strings" + "text/tabwriter" apiiam "github.com/tidbcloud/ti-cli/internal/api/iam" "github.com/tidbcloud/ti-cli/internal/apperr" @@ -40,6 +41,22 @@ type RoleUserStatus struct { Status string `json:"status"` } +func (r Result) Human() string { + var out strings.Builder + _, _ = fmt.Fprintf(&out, "Cluster ID: %s\n", r.ClusterID) + writer := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "ACCESS_MODE\tUSERNAME\tBUILTIN_ROLE\tAUTH_METHOD\tSTATUS") + for _, plan := range Plans() { + user, ok := r.Users[string(plan.Mode)] + if !ok { + continue + } + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", user.AccessMode, user.Username, user.BuiltinRole, user.AuthMethod, user.Status) + } + _ = writer.Flush() + return strings.TrimRight(out.String(), "\n") +} + type Plan struct { Mode sqlcred.AccessMode Suffix string diff --git a/internal/fs/control.go b/internal/fs/control.go index f195073..ac16c56 100644 --- a/internal/fs/control.go +++ b/internal/fs/control.go @@ -433,6 +433,34 @@ func (r FileSystemResult) Human() string { if r.CredentialsStored { lines = append(lines, "Credentials: stored locally") } + if r.FSToken != "" { + lines = append(lines, "FS token: "+r.FSToken) + } + return strings.Join(lines, "\n") +} + +func (r ListFileSystemsResult) Human() string { + var out strings.Builder + writer := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "FILE_SYSTEM_ID\tREGION\tSTATUS\tKIND\tLOCAL_TOKEN") + for _, fileSystem := range r.FileSystems { + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%t\n", fileSystem.FileSystemID, fileSystem.RegionCode, fileSystem.Status, fileSystem.Kind, fileSystem.HasLocalToken) + } + _ = writer.Flush() + return strings.TrimRight(out.String(), "\n") +} + +func (r DescribeFileSystemResult) Human() string { + lines := []string{ + "File system ID: " + r.FileSystemID, + "Region: " + r.RegionCode, + "Status: " + r.Status, + "Kind: " + r.Kind, + fmt.Sprintf("Local token: %t", r.HasLocalToken), + } + if r.Quota != nil { + lines = append(lines, fmt.Sprintf("Quota: %v", r.Quota)) + } return strings.Join(lines, "\n") } diff --git a/internal/fs/git.go b/internal/fs/git.go index f0c65cb..c24027b 100644 --- a/internal/fs/git.go +++ b/internal/fs/git.go @@ -206,6 +206,56 @@ type GitDeleteResult struct { Status string `json:"status"` } +func (r GitWorkspaceCloneResult) Human() string { + lines := []string{ + "Operation: " + r.Operation, + "Workspace ID: " + r.Workspace.WorkspaceID, + "Target path: " + r.TargetPath, + "Remote path: " + r.RemotePath, + "Head commit: " + r.HeadCommit, + fmt.Sprintf("Tree entries: %d", r.TreeEntries), + } + if r.BranchName != "" { + lines = append(lines, "Branch: "+r.BranchName) + } + if r.Hydrate != nil { + lines = append(lines, "Hydrate: "+r.Hydrate.Operation) + } + return strings.Join(lines, "\n") +} + +func (r GitHydrateResult) Human() string { + return strings.Join([]string{ + "Operation: " + r.Operation, + "Workspace ID: " + r.WorkspaceID, + "Target path: " + r.TargetPath, + "Commit: " + r.CommitSHA, + fmt.Sprintf("Files: %d", r.Files), + fmt.Sprintf("Objects: %d", r.Objects), + fmt.Sprintf("Skipped: %d", r.Skipped), + "Duration: " + r.Duration.String(), + }, "\n") +} + +func (r GitRestoreResult) Human() string { + return strings.Join([]string{ + "Operation: " + r.Operation, + "Workspace ID: " + r.WorkspaceID, + "Target path: " + r.TargetPath, + "Status: " + r.Status, + fmt.Sprintf("State restored: %t", r.StateRestored), + fmt.Sprintf("Object packs: %d", r.ObjectPacks), + }, "\n") +} + +func (r GitWorktreeRemoveResult) Human() string { + return strings.Join([]string{"Operation: " + r.Operation, "Workspace ID: " + r.WorkspaceID, "Remote path: " + r.RemotePath, "Status: " + r.Status}, "\n") +} + +func (r GitDeleteResult) Human() string { + return strings.Join([]string{"Operation: " + r.Operation, "ID: " + r.ID, "Status: " + r.Status}, "\n") +} + type mountedGitTarget struct { MountPoint string MountRel string diff --git a/internal/fs/pack.go b/internal/fs/pack.go index bb3e010..d8ee5f3 100644 --- a/internal/fs/pack.go +++ b/internal/fs/pack.go @@ -87,6 +87,29 @@ type UnpackFileSystemResult struct { CreatedAt time.Time `json:"created_at"` } +func (r PackFileSystemResult) Human() string { + return strings.Join([]string{ + "Status: " + r.Status, + "Archive path: " + r.ArchivePath, + "Local root: " + r.LocalRoot, + "Remote root: " + r.RemoteRoot, + fmt.Sprintf("Entries: %d", r.Entries), + fmt.Sprintf("Archive bytes: %d", r.ArchiveSizeBytes), + fmt.Sprintf("Uploaded bytes: %d", r.UploadedBytes), + }, "\n") +} + +func (r UnpackFileSystemResult) Human() string { + return strings.Join([]string{ + "Status: " + r.Status, + "Archive path: " + r.ArchivePath, + "Local root: " + r.LocalRoot, + "Remote root: " + r.RemoteRoot, + fmt.Sprintf("Entries: %d", r.Entries), + fmt.Sprintf("Replaced: %t", r.Replaced), + }, "\n") +} + type packProfileConfig struct { Name string PackPaths []string diff --git a/internal/fs/tokenmgmt/service.go b/internal/fs/tokenmgmt/service.go index 0243da6..272a7c8 100644 --- a/internal/fs/tokenmgmt/service.go +++ b/internal/fs/tokenmgmt/service.go @@ -1104,3 +1104,78 @@ func (r ListResult) Human() string { _ = w.Flush() return strings.TrimRight(out.String(), "\n") } + +func (r GenerateResult) Human() string { + lines := []string{ + "File system ID: " + r.FileSystemID, + "Token ID: " + r.TokenID, + "Token name: " + r.TokenName, + "Scope: " + r.ScopeKind, + "Status: " + r.Status, + "Expires: " + formatTokenExpiry(r.ExpiresAt), + fmt.Sprintf("Credentials stored: %t", r.CredentialsStored), + "FS token: " + r.FSToken, + } + if r.PreviousTokenNote != "" { + lines = append(lines, "Note: "+r.PreviousTokenNote) + } + return strings.Join(lines, "\n") +} + +func (r GenerateScopedResult) Human() string { + var out strings.Builder + _, _ = fmt.Fprintf(&out, "File system ID: %s\nToken ID: %s\nScope: %s\nExpires: %s\nCredentials stored: %t\nFS token: %s\n", r.FileSystemID, r.TokenID, r.ScopeKind, formatTokenExpiry(r.ExpiresAt), r.CredentialsStored, r.FSToken) + if r.Subject != "" { + _, _ = fmt.Fprintf(&out, "Subject: %s\n", r.Subject) + } + if r.PreviousTokenNote != "" { + _, _ = fmt.Fprintf(&out, "Note: %s\n", r.PreviousTokenNote) + } + if len(r.Scopes) > 0 { + writer := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "PREFIX\tOPERATIONS") + for _, scope := range r.Scopes { + _, _ = fmt.Fprintf(writer, "%s\t%s\n", scope.Prefix, strings.Join(scope.Ops, ",")) + } + _ = writer.Flush() + } + return strings.TrimRight(out.String(), "\n") +} + +func (r MutationResult) Human() string { + lines := []string{ + "File system ID: " + r.FileSystemID, + "Token ID: " + r.TokenID, + "Status: " + r.Status, + fmt.Sprintf("Local credentials updated: %t", r.LocalCredentialsUpdated), + } + if r.LocalCredentialsReason != "" { + lines = append(lines, "Local credentials: "+r.LocalCredentialsReason) + } + if r.CacheConvergenceNote != "" { + lines = append(lines, "Note: "+r.CacheConvergenceNote) + } + return strings.Join(lines, "\n") +} + +func (r RefreshResult) Human() string { + lines := []string{ + "File system ID: " + r.FileSystemID, + "Token ID: " + r.TokenID, + "Scope: " + r.ScopeKind, + "Expires: " + formatTokenExpiry(r.ExpiresAt), + fmt.Sprintf("Credentials stored: %t", r.CredentialsStored), + "FS token: " + r.FSToken, + } + if r.RecoveryPath != "" { + lines = append(lines, "Recovery path: "+r.RecoveryPath) + } + return strings.Join(lines, "\n") +} + +func formatTokenExpiry(expiresAt *time.Time) string { + if expiresAt == nil { + return "never" + } + return expiresAt.UTC().Format(time.RFC3339) +} diff --git a/internal/output/command_contract_test.go b/internal/output/command_contract_test.go new file mode 100644 index 0000000..0175c73 --- /dev/null +++ b/internal/output/command_contract_test.go @@ -0,0 +1,90 @@ +package output_test + +import ( + "bytes" + "strings" + "testing" + + configure "github.com/tidbcloud/ti-cli/internal/config/configure" + "github.com/tidbcloud/ti-cli/internal/db" + "github.com/tidbcloud/ti-cli/internal/db/connectionstring" + "github.com/tidbcloud/ti-cli/internal/db/sqlresult" + "github.com/tidbcloud/ti-cli/internal/dryrun" + "github.com/tidbcloud/ti-cli/internal/fs" + "github.com/tidbcloud/ti-cli/internal/fs/tokenmgmt" + "github.com/tidbcloud/ti-cli/internal/output" + "github.com/tidbcloud/ti-cli/internal/update" +) + +func TestRegisteredCommandResultsSupportTextOutput(t *testing.T) { + results := map[string]any{ + "configure": configure.Result{}, + "update check": update.CheckResult{}, + "update apply": update.ApplyResult{}, + "dry run": dryrun.Result{}, + "db list clusters": db.ListClustersResult{}, + "db cluster": db.ClusterResult{}, + "db list branches": db.ListBranchesResult{}, + "db branch": db.BranchResult{}, + "db create sql users": db.PrepareQueryAccessResult{}, + "db connection string": connectionstring.Result{}, + "db execute sql": sqlresult.Result{}, + "fs create": fs.FileSystemResult{}, + "fs list file systems": fs.ListFileSystemsResult{}, + "fs describe file system": fs.DescribeFileSystemResult{}, + "fs delete": fs.DeleteResult{}, + "fs import token": fs.ImportFileSystemTokenResult{}, + "fs check": fs.CheckResult{}, + "fs file operation": fs.FileOperationResult{}, + "fs list files": fs.ListFilesResult{}, + "fs describe file": fs.DescribeFileResult{}, + "fs search files": fs.SearchFilesResult{}, + "fs layer": fs.LayerResult{}, + "fs list layers": fs.LayerListResult{}, + "fs layer entries": fs.LayerEntriesResult{}, + "fs layer entry": fs.LayerEntryResult{}, + "fs layer checkpoint": fs.LayerCheckpointResult{}, + "fs layer events": fs.LayerEventsResult{}, + "fs layer action": fs.LayerActionResult{}, + "fs layer commit": fs.LayerCommitResult{}, + "fs pack": fs.PackFileSystemResult{}, + "fs unpack": fs.UnpackFileSystemResult{}, + "fs mount": fs.MountResult{}, + "fs unmount": fs.UnmountResult{}, + "fs drain": fs.DrainResult{}, + "fs generate owner token": tokenmgmt.GenerateResult{}, + "fs generate scoped token": tokenmgmt.GenerateScopedResult{}, + "fs list tokens": tokenmgmt.ListResult{}, + "fs mutate token": tokenmgmt.MutationResult{}, + "fs refresh token": tokenmgmt.RefreshResult{}, + "fs vault secret": fs.VaultSecretResult{}, + "fs vault read secret": fs.VaultReadSecretResult{}, + "fs vault list secrets": fs.VaultListSecretsResult{}, + "fs vault delete": fs.VaultDeleteResult{}, + "fs vault token": fs.VaultTokenResult{}, + "fs vault audit": fs.VaultAuditResult{}, + "fs journal": fs.JournalResult{}, + "fs journal entries": fs.JournalEntriesResult{}, + "fs journal search": fs.JournalSearchResult{}, + "fs journal append": fs.JournalAppendResult{}, + "fs journal verify": fs.JournalVerifyResult{}, + "fs git clone or add worktree": fs.GitWorkspaceCloneResult{}, + "fs git hydrate": fs.GitHydrateResult{}, + "fs git restore": fs.GitRestoreResult{}, + "fs git remove worktree": fs.GitWorktreeRemoveResult{}, + "fs git delete internal resource": fs.GitDeleteResult{}, + } + + for name, result := range results { + t.Run(name, func(t *testing.T) { + var rendered bytes.Buffer + if err := output.Render(&rendered, result, output.Options{Format: output.FormatText}); err != nil { + t.Fatalf("render %T: %v", result, err) + } + trimmed := strings.TrimSpace(rendered.String()) + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + t.Fatalf("text output fell back to JSON:\n%s", rendered.String()) + } + }) + } +} diff --git a/internal/output/output.go b/internal/output/output.go index 533562d..eb2493e 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -4,7 +4,9 @@ import ( "encoding/json" "fmt" "io" + "sort" "strings" + "text/tabwriter" "github.com/tidbcloud/ti-cli/internal/apperr" "github.com/tidbcloud/ti-cli/internal/query" @@ -56,7 +58,8 @@ func Render(w io.Writer, value any, opts Options) error { return err } - if opts.Query != "" { + queried := opts.Query != "" + if queried { result, err := query.Apply(opts.Query, value) if err != nil { return apperr.Wrap( @@ -74,6 +77,9 @@ func Render(w io.Writer, value any, opts Options) error { case FormatJSON: return renderJSON(w, value) case FormatText: + if queried { + return renderQueryText(w, value) + } return renderHuman(w, value) default: panic("unreachable output format") @@ -111,6 +117,106 @@ func renderHuman(w io.Writer, value any) error { _, err := fmt.Fprintln(w, typed) return err default: - return renderJSON(w, value) + return apperr.New( + "output.text_formatter_missing", + "runtime", + 1, + fmt.Sprintf("text output is not implemented for result type %T", value), + ) + } +} + +func renderQueryText(w io.Writer, value any) error { + switch typed := value.(type) { + case nil: + _, err := fmt.Fprintln(w, "null") + return err + case string, bool, float64: + _, err := fmt.Fprintln(w, textCell(typed)) + return err + case []any: + return renderQueryList(w, typed) + case map[string]any: + return renderQueryObject(w, typed) + default: + return apperr.New("output.query_text_type", "runtime", 1, fmt.Sprintf("cannot render queried result type %T as text", value)) + } +} + +func renderQueryList(w io.Writer, values []any) error { + if len(values) == 0 { + return nil + } + objects := make([]map[string]any, 0, len(values)) + for _, value := range values { + object, ok := value.(map[string]any) + if !ok { + for _, item := range values { + if _, err := fmt.Fprintln(w, textCell(item)); err != nil { + return err + } + } + return nil + } + objects = append(objects, object) + } + keys := queryObjectKeys(objects...) + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, strings.Join(keys, "\t")); err != nil { + return err + } + for _, object := range objects { + cells := make([]string, 0, len(keys)) + for _, key := range keys { + cells = append(cells, textCell(object[key])) + } + if _, err := fmt.Fprintln(tw, strings.Join(cells, "\t")); err != nil { + return err + } + } + return tw.Flush() +} + +func renderQueryObject(w io.Writer, object map[string]any) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, key := range queryObjectKeys(object) { + if _, err := fmt.Fprintf(tw, "%s\t%s\n", key, textCell(object[key])); err != nil { + return err + } + } + return tw.Flush() +} + +func queryObjectKeys(objects ...map[string]any) []string { + seen := make(map[string]struct{}) + for _, object := range objects { + for key := range object { + seen[key] = struct{}{} + } + } + keys := make([]string, 0, len(seen)) + for key := range seen { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func textCell(value any) string { + switch typed := value.(type) { + case nil: + return "null" + case string: + return typed + case bool: + return fmt.Sprint(typed) + case float64: + return fmt.Sprint(typed) + default: + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Sprint(value) + } + return string(encoded) } } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 90333c9..bd77c3e 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -38,6 +38,39 @@ func TestRenderText(t *testing.T) { } } +func TestRenderTextRejectsMissingFormatter(t *testing.T) { + var out bytes.Buffer + err := Render(&out, map[string]any{"id": "cluster-1"}, Options{Format: FormatText}) + if apperr.CodeFor(err) != "output.text_formatter_missing" { + t.Fatalf("error = %v", err) + } +} + +func TestRenderQueryText(t *testing.T) { + tests := []struct { + name string + query string + value any + want string + }{ + {name: "scalar", query: "id", value: map[string]any{"id": "cluster-1"}, want: "cluster-1\n"}, + {name: "scalar list", query: "ids", value: map[string]any{"ids": []string{"one", "two"}}, want: "one\ntwo\n"}, + {name: "object", query: "item", value: map[string]any{"item": map[string]any{"state": "ACTIVE", "id": "one"}}, want: "id one\nstate ACTIVE\n"}, + {name: "object list", query: "items", value: map[string]any{"items": []map[string]any{{"id": "one", "state": "ACTIVE"}, {"id": "two", "state": "PAUSED"}}}, want: "id state\none ACTIVE\ntwo PAUSED\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var out bytes.Buffer + if err := Render(&out, tt.value, Options{Format: FormatText, Query: tt.query}); err != nil { + t.Fatal(err) + } + if got := out.String(); got != tt.want { + t.Fatalf("output = %q, want %q", got, tt.want) + } + }) + } +} + func TestRenderAppliesQueryBeforeRendering(t *testing.T) { var out bytes.Buffer value := map[string]any{