From c5132d2a587649bd04c04cee8988d9e79ef54df3 Mon Sep 17 00:00:00 2001 From: Ygal Blum Date: Thu, 12 Mar 2026 14:48:19 -0400 Subject: [PATCH] Add output formatting package (Topic 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the output formatting layer with support for table, JSON, and YAML formats. The Formatter struct provides FormatOne, FormatList, FormatMessage, and FormatError methods with stdout/stderr separation. Includes 18 Ginkgo specs covering TC-U009–TC-U018 and TC-U116–TC-U119. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ygal Blum --- .ai/checkpoints/topic-3-output-formatting.md | 88 +++++ go.mod | 2 +- internal/output/formatter.go | 184 ++++++++++ internal/output/formatter_test.go | 358 +++++++++++++++++++ internal/output/output_suite_test.go | 13 + 5 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 .ai/checkpoints/topic-3-output-formatting.md create mode 100644 internal/output/formatter.go create mode 100644 internal/output/formatter_test.go create mode 100644 internal/output/output_suite_test.go diff --git a/.ai/checkpoints/topic-3-output-formatting.md b/.ai/checkpoints/topic-3-output-formatting.md new file mode 100644 index 0000000..5482613 --- /dev/null +++ b/.ai/checkpoints/topic-3-output-formatting.md @@ -0,0 +1,88 @@ +# Checkpoint: Topic 3 — Output Formatting + +- **Branch:** `topic-3-output-formatting` +- **Base:** `topic-1-cli-framework` (commit `5a59418`) +- **Date:** 2026-03-12 +- **Status:** Complete (pending commit) + +--- + +## Scope + +Topic 3 implements the output formatting package per spec section 4.3. It provides a `Formatter` struct that renders single resources, resource lists, status messages, and errors in table, JSON, or YAML format. Success output goes to stdout; error output goes to stderr. + +No dependencies on Topic 2 (Configuration) were required. + +### Requirements Addressed + +| ID | Description | Status | +|----|-------------|--------| +| REQ-OUT-010 | Three output formats: `table`, `json`, `yaml` | Done | +| REQ-OUT-020 | Default output format is `table` | Done (via `ParseFormat` + default in root flag) | +| REQ-OUT-030 | Output format selectable via `--output`/`-o` flag | Done (flag exists from Topic 1; `ParseFormat` validates) | +| REQ-OUT-050 | Table output with fixed column headers per resource type | Done (`TableDef` with `Headers` and `RowFunc`) | +| REQ-OUT-060 | JSON output produces valid JSON | Done | +| REQ-OUT-070 | YAML output produces valid YAML | Done | +| REQ-OUT-080 | JSON/YAML list output includes `next_page_token` when present | Done | +| REQ-OUT-090 | Table list output shows pagination hint when `next_page_token` present | Done | +| REQ-OUT-100 | Invalid output format values rejected | Done (`ParseFormat` returns error) | +| REQ-OUT-110 | Success output to stdout, error output to stderr | Done (separate `out`/`errOut` writers) | +| REQ-OUT-120 | `FormatError` renders API errors per format | Done | + +### Tests Implemented (18 specs) + +| TC ID | Description | Status | +|-------|-------------|--------| +| TC-U009 | Table output for single resource with headers and values | Pass | +| TC-U010 | Table output for 3-resource list, no pagination hint | Pass | +| TC-U011 | JSON output is valid, parseable JSON with correct fields | Pass | +| TC-U012 | YAML output is valid, parseable YAML with correct fields | Pass | +| TC-U013 | Table list output shows pagination hint with token | Pass | +| TC-U014 | JSON list output includes `next_page_token` | Pass | +| TC-U015 | YAML list output includes `next_page_token` | Pass | +| TC-U016 | No pagination hint when `nextPageToken` is empty | Pass | +| TC-U017 | `ParseFormat("invalid")` returns error | Pass | +| TC-U018 | `FormatMessage` writes to stdout, nothing to stderr | Pass | +| TC-U116 | `FormatError` writes to stderr, stdout is empty | Pass | +| TC-U117 | `FormatError` table format: `Error: TYPE - TITLE`, `Status`, `Detail` | Pass | +| TC-U118 | `FormatError` JSON format: full Problem Details object to stderr | Pass | +| TC-U119 | `FormatError` YAML format: full Problem Details object to stderr | Pass | +| — | Valid format strings accepted by `ParseFormat` | Pass | +| — | Empty table list renders headers only | Pass | +| — | Empty JSON list renders `[]` results array | Pass | +| — | Empty YAML list renders empty results sequence | Pass | + +--- + +## Files Created + +| File | Purpose | +|------|---------| +| `internal/output/formatter.go` | `Formatter` struct, `Format` type, `ProblemDetail`, `TableDef`, `ListResponse`, `ParseFormat` | +| `internal/output/formatter_test.go` | Ginkgo tests for TC-U009–TC-U018, TC-U116–TC-U119, plus edge cases | +| `internal/output/output_suite_test.go` | Ginkgo test suite bootstrap | + +--- + +## Key Design Decisions + +1. **Concrete struct, not interface** — `Formatter` is a struct rather than an interface. Commands receive a `*Formatter` directly. This keeps the API simple; an interface can be introduced later if needed for testing command logic. + +2. **Separate `out` and `errOut` writers** — The constructor takes two `io.Writer` parameters. `FormatOne`, `FormatList`, and `FormatMessage` write to `out` (stdout). `FormatError` writes to `errOut` (stderr). This satisfies REQ-OUT-110. + +3. **`TableDef` injection** — Table column layout is defined per resource type via `TableDef` (headers + row extraction function). The output package is generic; resource-specific table definitions will be provided by command implementations in Topics 4–7. + +4. **`command` parameter for pagination hints** — The `Formatter` receives the base command string (e.g., `"policy list --page-size 2"`) so pagination hints render as `Next page: dcm --page-token `. + +5. **`ParseFormat` for validation** — A standalone function validates format strings and returns a typed `Format`. Commands will call this to convert the `--output` flag value, returning a usage error on invalid input (REQ-OUT-100). + +6. **Nil-safe empty lists** — `FormatList` normalises `nil` resource slices to empty `[]any{}` so JSON/YAML always render `"results": []` rather than `null`. + +--- + +## What's Next + +- **Topic 2: Configuration Management** — In progress on separate branch `topic-2-configuration` +- **Topic 8: Version Command** — Depends only on Topic 1 +- **Topics 4–7: Command implementations** — Depend on Topics 1, 2, 3; will wire `Formatter` into command `RunE` functions with resource-specific `TableDef` definitions +- **TC-U116 full verification** — Command-level test (policy get → mock 404 → stderr output) will be implemented in Topic 4 diff --git a/go.mod b/go.mod index ddc93f6..47a8ce4 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v3 v3.0.4 ) require ( @@ -16,7 +17,6 @@ require ( github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/internal/output/formatter.go b/internal/output/formatter.go new file mode 100644 index 0000000..ce67d8d --- /dev/null +++ b/internal/output/formatter.go @@ -0,0 +1,184 @@ +// Package output provides output formatting for the dcm CLI. +// It supports table, JSON, and YAML output formats for rendering +// single resources, resource lists, status messages, and errors. +package output + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" + + "go.yaml.in/yaml/v3" +) + +// Format represents an output format. +type Format string + +const ( + FormatTable Format = "table" + FormatJSON Format = "json" + FormatYAML Format = "yaml" +) + +// ValidFormats returns the list of supported output format values. +func ValidFormats() []string { + return []string{string(FormatTable), string(FormatJSON), string(FormatYAML)} +} + +// ParseFormat validates and returns a Format from a string value. +// It returns an error if the format is not supported. +func ParseFormat(s string) (Format, error) { + switch Format(s) { + case FormatTable, FormatJSON, FormatYAML: + return Format(s), nil + default: + return "", fmt.Errorf("invalid output format %q: must be one of %s", s, strings.Join(ValidFormats(), ", ")) + } +} + +// ProblemDetail represents an RFC 7807 Problem Details error. +type ProblemDetail struct { + Type string `json:"type" yaml:"type"` + Status int `json:"status" yaml:"status"` + Title string `json:"title" yaml:"title"` + Detail string `json:"detail" yaml:"detail"` +} + +// TableDef defines the table layout for a resource type. +type TableDef struct { + // Headers are the column header names. + Headers []string + // RowFunc extracts column values from a single resource. + RowFunc func(resource any) []string +} + +// Formatter formats CLI output in a specific format. +type Formatter struct { + format Format + out io.Writer + errOut io.Writer + table *TableDef + command string +} + +// New creates a Formatter for the given format. +// out is the writer for success output (stdout), +// errOut is the writer for error output (stderr). +func New(format Format, out, errOut io.Writer, table *TableDef, command string) *Formatter { + return &Formatter{ + format: format, + out: out, + errOut: errOut, + table: table, + command: command, + } +} + +// FormatOne formats a single resource. +func (f *Formatter) FormatOne(resource any) error { + switch f.format { + case FormatJSON: + return f.writeJSON(f.out, resource) + case FormatYAML: + return f.writeYAML(f.out, resource) + case FormatTable: + return f.writeTable([]any{resource}) + default: + return fmt.Errorf("unsupported format: %s", f.format) + } +} + +// ListResponse wraps a list of resources with an optional pagination token +// for JSON/YAML output. +type ListResponse struct { + Results []any `json:"results" yaml:"results"` + NextPageToken string `json:"next_page_token,omitempty" yaml:"next_page_token,omitempty"` +} + +// FormatList formats a list of resources with optional pagination info. +func (f *Formatter) FormatList(resources []any, nextPageToken string) error { + switch f.format { + case FormatJSON: + resp := ListResponse{Results: resources, NextPageToken: nextPageToken} + if resp.Results == nil { + resp.Results = []any{} + } + return f.writeJSON(f.out, resp) + case FormatYAML: + resp := ListResponse{Results: resources, NextPageToken: nextPageToken} + if resp.Results == nil { + resp.Results = []any{} + } + return f.writeYAML(f.out, resp) + case FormatTable: + if err := f.writeTable(resources); err != nil { + return err + } + if nextPageToken != "" { + _, err := fmt.Fprintf(f.out, "\nNext page: dcm %s --page-token %s\n", f.command, nextPageToken) + return err + } + return nil + default: + return fmt.Errorf("unsupported format: %s", f.format) + } +} + +// FormatMessage formats a simple status message to stdout. +func (f *Formatter) FormatMessage(msg string) error { + _, err := fmt.Fprintln(f.out, msg) + return err +} + +// FormatError formats an error to stderr. +func (f *Formatter) FormatError(problem ProblemDetail) error { + switch f.format { + case FormatJSON: + return f.writeJSON(f.errOut, problem) + case FormatYAML: + return f.writeYAML(f.errOut, problem) + case FormatTable: + _, err := fmt.Fprintf(f.errOut, "Error: %s - %s\n Status: %d\n Detail: %s\n", + problem.Type, problem.Title, problem.Status, problem.Detail) + return err + default: + return fmt.Errorf("unsupported format: %s", f.format) + } +} + +func (f *Formatter) writeJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func (f *Formatter) writeYAML(w io.Writer, v any) error { + enc := yaml.NewEncoder(w) + if err := enc.Encode(v); err != nil { + return err + } + return enc.Close() +} + +func (f *Formatter) writeTable(resources []any) error { + if f.table == nil { + return fmt.Errorf("table definition not set") + } + w := tabwriter.NewWriter(f.out, 0, 0, 2, ' ', 0) + // Write headers + _, err := fmt.Fprintln(w, strings.Join(f.table.Headers, "\t")) + if err != nil { + return err + } + // Write rows + for _, r := range resources { + cols := f.table.RowFunc(r) + _, err = fmt.Fprintln(w, strings.Join(cols, "\t")) + if err != nil { + return err + } + } + return w.Flush() +} diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go new file mode 100644 index 0000000..232ad29 --- /dev/null +++ b/internal/output/formatter_test.go @@ -0,0 +1,358 @@ +package output_test + +import ( + "bytes" + "encoding/json" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.yaml.in/yaml/v3" + + "github.com/dcm-project/cli/internal/output" +) + +// testResource represents a sample resource for testing. +type testResource struct { + ID string `json:"id" yaml:"id"` + DisplayName string `json:"display_name" yaml:"display_name"` + Type string `json:"type" yaml:"type"` + Priority int `json:"priority" yaml:"priority"` + Enabled bool `json:"enabled" yaml:"enabled"` + Created string `json:"created" yaml:"created"` +} + +func policyTableDef() *output.TableDef { + return &output.TableDef{ + Headers: []string{"ID", "DISPLAY NAME", "TYPE", "PRIORITY", "ENABLED", "CREATED"}, + RowFunc: func(resource any) []string { + r, ok := resource.(testResource) + if !ok { + return []string{} + } + return []string{ + r.ID, + r.DisplayName, + r.Type, + fmt.Sprintf("%d", r.Priority), + fmt.Sprintf("%t", r.Enabled), + r.Created, + } + }, + } +} + +var _ = Describe("Output Formatting", func() { + var ( + stdout *bytes.Buffer + stderr *bytes.Buffer + ) + + BeforeEach(func() { + stdout = new(bytes.Buffer) + stderr = new(bytes.Buffer) + }) + + sampleResource := testResource{ + ID: "my-policy", + DisplayName: "Require CPU Limits", + Type: "GLOBAL", + Priority: 100, + Enabled: true, + Created: "2026-03-09T10:00:00Z", + } + + sampleResources := []any{ + testResource{ + ID: "policy-1", + DisplayName: "Policy One", + Type: "GLOBAL", + Priority: 100, + Enabled: true, + Created: "2026-03-09T10:00:00Z", + }, + testResource{ + ID: "policy-2", + DisplayName: "Policy Two", + Type: "USER", + Priority: 200, + Enabled: false, + Created: "2026-03-08T15:30:00Z", + }, + testResource{ + ID: "policy-3", + DisplayName: "Policy Three", + Type: "GLOBAL", + Priority: 300, + Enabled: true, + Created: "2026-03-07T12:00:00Z", + }, + } + + sampleProblem := output.ProblemDetail{ + Type: "NOT_FOUND", + Status: 404, + Title: `Policy "nonexistent" not found.`, + Detail: "The requested policy resource does not exist.", + } + + Describe("Table Output", func() { + // TC-U009: Table output for single resource + It("TC-U009: should display a single resource in tabular format with headers", func() { + f := output.New(output.FormatTable, stdout, stderr, policyTableDef(), "policy list") + + err := f.FormatOne(sampleResource) + Expect(err).NotTo(HaveOccurred()) + + out := stdout.String() + Expect(out).To(ContainSubstring("ID")) + Expect(out).To(ContainSubstring("DISPLAY NAME")) + Expect(out).To(ContainSubstring("TYPE")) + Expect(out).To(ContainSubstring("PRIORITY")) + Expect(out).To(ContainSubstring("ENABLED")) + Expect(out).To(ContainSubstring("CREATED")) + Expect(out).To(ContainSubstring("my-policy")) + Expect(out).To(ContainSubstring("Require CPU Limits")) + Expect(out).To(ContainSubstring("GLOBAL")) + Expect(out).To(ContainSubstring("100")) + Expect(out).To(ContainSubstring("true")) + Expect(out).To(ContainSubstring("2026-03-09T10:00:00Z")) + Expect(stderr.String()).To(BeEmpty()) + }) + + // TC-U010: Table output for resource list + It("TC-U010: should display a list of resources in tabular format with no pagination hint", func() { + f := output.New(output.FormatTable, stdout, stderr, policyTableDef(), "policy list") + + err := f.FormatList(sampleResources, "") + Expect(err).NotTo(HaveOccurred()) + + out := stdout.String() + Expect(out).To(ContainSubstring("ID")) + Expect(out).To(ContainSubstring("policy-1")) + Expect(out).To(ContainSubstring("policy-2")) + Expect(out).To(ContainSubstring("policy-3")) + Expect(out).NotTo(ContainSubstring("Next page:")) + Expect(stderr.String()).To(BeEmpty()) + }) + + // TC-U013: Pagination hint in table output + It("TC-U013: should display a pagination hint when nextPageToken is present", func() { + f := output.New(output.FormatTable, stdout, stderr, policyTableDef(), "policy list --page-size 2") + + err := f.FormatList(sampleResources[:2], "eyJvZmZzZXQiOjJ9") + Expect(err).NotTo(HaveOccurred()) + + out := stdout.String() + Expect(out).To(ContainSubstring("Next page: dcm policy list --page-size 2 --page-token eyJvZmZzZXQiOjJ9")) + }) + + // TC-U016: No pagination hint when nextPageToken is empty + It("TC-U016: should not display a pagination hint when nextPageToken is empty", func() { + f := output.New(output.FormatTable, stdout, stderr, policyTableDef(), "policy list") + + err := f.FormatList(sampleResources, "") + Expect(err).NotTo(HaveOccurred()) + + Expect(stdout.String()).NotTo(ContainSubstring("Next page:")) + }) + }) + + Describe("JSON Output", func() { + // TC-U011: JSON output produces valid JSON + It("TC-U011: should produce valid, parseable JSON for a single resource", func() { + f := output.New(output.FormatJSON, stdout, stderr, nil, "") + + err := f.FormatOne(sampleResource) + Expect(err).NotTo(HaveOccurred()) + + var parsed map[string]any + err = json.Unmarshal(stdout.Bytes(), &parsed) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed["id"]).To(Equal("my-policy")) + Expect(parsed["display_name"]).To(Equal("Require CPU Limits")) + Expect(parsed["type"]).To(Equal("GLOBAL")) + Expect(stderr.String()).To(BeEmpty()) + }) + + // TC-U014: Pagination token in JSON output + It("TC-U014: should include next_page_token in JSON list output", func() { + f := output.New(output.FormatJSON, stdout, stderr, nil, "") + + err := f.FormatList(sampleResources, "abc123") + Expect(err).NotTo(HaveOccurred()) + + var parsed map[string]any + err = json.Unmarshal(stdout.Bytes(), &parsed) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed["next_page_token"]).To(Equal("abc123")) + Expect(parsed["results"]).To(HaveLen(3)) + }) + }) + + Describe("YAML Output", func() { + // TC-U012: YAML output produces valid YAML + It("TC-U012: should produce valid, parseable YAML for a single resource", func() { + f := output.New(output.FormatYAML, stdout, stderr, nil, "") + + err := f.FormatOne(sampleResource) + Expect(err).NotTo(HaveOccurred()) + + var parsed map[string]any + err = yaml.Unmarshal(stdout.Bytes(), &parsed) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed["id"]).To(Equal("my-policy")) + Expect(parsed["display_name"]).To(Equal("Require CPU Limits")) + Expect(parsed["type"]).To(Equal("GLOBAL")) + Expect(stderr.String()).To(BeEmpty()) + }) + + // TC-U015: Pagination token in YAML output + It("TC-U015: should include next_page_token in YAML list output", func() { + f := output.New(output.FormatYAML, stdout, stderr, nil, "") + + err := f.FormatList(sampleResources, "abc123") + Expect(err).NotTo(HaveOccurred()) + + var parsed map[string]any + err = yaml.Unmarshal(stdout.Bytes(), &parsed) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed["next_page_token"]).To(Equal("abc123")) + results, ok := parsed["results"].([]any) + Expect(ok).To(BeTrue()) + Expect(results).To(HaveLen(3)) + }) + }) + + Describe("Format Validation", func() { + // TC-U017: Invalid output format rejected + It("TC-U017: should return an error for an invalid output format", func() { + _, err := output.ParseFormat("invalid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid output format")) + }) + + It("should accept valid formats", func() { + for _, valid := range []string{"table", "json", "yaml"} { + f, err := output.ParseFormat(valid) + Expect(err).NotTo(HaveOccurred()) + Expect(string(f)).To(Equal(valid)) + } + }) + }) + + Describe("FormatMessage", func() { + // TC-U018: FormatMessage displays status message + It("TC-U018: should write the message to stdout and nothing to stderr", func() { + f := output.New(output.FormatTable, stdout, stderr, nil, "") + + err := f.FormatMessage(`Policy "my-policy" deleted successfully.`) + Expect(err).NotTo(HaveOccurred()) + + Expect(stdout.String()).To(ContainSubstring(`Policy "my-policy" deleted successfully.`)) + Expect(stderr.String()).To(BeEmpty()) + }) + }) + + Describe("FormatError", func() { + // TC-U116: Error output written to stderr (formatter-level verification) + It("TC-U116: should write error output to stderr and not to stdout", func() { + f := output.New(output.FormatTable, stdout, stderr, nil, "") + + err := f.FormatError(sampleProblem) + Expect(err).NotTo(HaveOccurred()) + + Expect(stdout.String()).To(BeEmpty()) + Expect(stderr.String()).NotTo(BeEmpty()) + }) + + // TC-U117: FormatError renders error in table format + It("TC-U117: should render error in table format to stderr", func() { + f := output.New(output.FormatTable, stdout, stderr, nil, "") + + err := f.FormatError(sampleProblem) + Expect(err).NotTo(HaveOccurred()) + + errOutput := stderr.String() + Expect(errOutput).To(ContainSubstring(`Error: NOT_FOUND - Policy "nonexistent" not found.`)) + Expect(errOutput).To(ContainSubstring("Status: 404")) + Expect(errOutput).To(ContainSubstring("Detail: The requested policy resource does not exist.")) + Expect(stdout.String()).To(BeEmpty()) + }) + + // TC-U118: FormatError renders error in JSON format + It("TC-U118: should render the full Problem Details JSON to stderr", func() { + f := output.New(output.FormatJSON, stdout, stderr, nil, "") + + err := f.FormatError(sampleProblem) + Expect(err).NotTo(HaveOccurred()) + + var parsed map[string]any + err = json.Unmarshal(stderr.Bytes(), &parsed) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed["type"]).To(Equal("NOT_FOUND")) + Expect(parsed["status"]).To(BeNumerically("==", 404)) + Expect(parsed["title"]).To(Equal(`Policy "nonexistent" not found.`)) + Expect(parsed["detail"]).To(Equal("The requested policy resource does not exist.")) + Expect(stdout.String()).To(BeEmpty()) + }) + + // TC-U119: FormatError renders error in YAML format + It("TC-U119: should render the full Problem Details YAML to stderr", func() { + f := output.New(output.FormatYAML, stdout, stderr, nil, "") + + err := f.FormatError(sampleProblem) + Expect(err).NotTo(HaveOccurred()) + + var parsed map[string]any + err = yaml.Unmarshal(stderr.Bytes(), &parsed) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed["type"]).To(Equal("NOT_FOUND")) + Expect(parsed["status"]).To(BeNumerically("==", 404)) + Expect(parsed["title"]).To(Equal(`Policy "nonexistent" not found.`)) + Expect(parsed["detail"]).To(Equal("The requested policy resource does not exist.")) + Expect(stdout.String()).To(BeEmpty()) + }) + }) + + Describe("Empty List", func() { + It("should render empty table with headers only", func() { + f := output.New(output.FormatTable, stdout, stderr, policyTableDef(), "policy list") + + err := f.FormatList([]any{}, "") + Expect(err).NotTo(HaveOccurred()) + + out := stdout.String() + Expect(out).To(ContainSubstring("ID")) + Expect(out).To(ContainSubstring("DISPLAY NAME")) + }) + + It("should render empty JSON array", func() { + f := output.New(output.FormatJSON, stdout, stderr, nil, "") + + err := f.FormatList(nil, "") + Expect(err).NotTo(HaveOccurred()) + + var parsed map[string]any + err = json.Unmarshal(stdout.Bytes(), &parsed) + Expect(err).NotTo(HaveOccurred()) + results, ok := parsed["results"].([]any) + Expect(ok).To(BeTrue()) + Expect(results).To(BeEmpty()) + }) + + It("should render empty YAML list", func() { + f := output.New(output.FormatYAML, stdout, stderr, nil, "") + + err := f.FormatList(nil, "") + Expect(err).NotTo(HaveOccurred()) + + var parsed map[string]any + err = yaml.Unmarshal(stdout.Bytes(), &parsed) + Expect(err).NotTo(HaveOccurred()) + results, ok := parsed["results"].([]any) + Expect(ok).To(BeTrue()) + Expect(results).To(BeEmpty()) + }) + }) +}) diff --git a/internal/output/output_suite_test.go b/internal/output/output_suite_test.go new file mode 100644 index 0000000..262b159 --- /dev/null +++ b/internal/output/output_suite_test.go @@ -0,0 +1,13 @@ +package output_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestOutput(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Output Suite") +}