From fed45d71bdb95c5fb0d4b29d02ed4fd3d8332282 Mon Sep 17 00:00:00 2001 From: tytv2 Date: Wed, 1 Jul 2026 14:29:08 +0700 Subject: [PATCH] feat(output): implement --color (status coloring, aws-style) The --color flag was accepted, validated, and completed but did nothing. Wire it end-to-end: formatter.ColorEnabled resolves on/off/auto exactly like the AWS CLI (auto = stdout is a terminal and NO_COLOR is unset), and text/table output now colors recognized status values (ACTIVE/READY green, ERROR/FAILED red, CREATING/PENDING/UPDATING yellow). JSON output is never colored so it stays valid. Column widths are computed on the raw text, so ANSI codes don't break table alignment. Applied to both the VKS and vServer output paths. Co-Authored-By: Claude Opus 4.8 --- .../next-release/feature-output-kv9v6h8p.json | 5 ++ go/internal/cli/output.go | 3 +- go/internal/formatter/color.go | 74 +++++++++++++++++++ go/internal/formatter/color_test.go | 70 ++++++++++++++++++ go/internal/formatter/formatter.go | 72 +++++++++++++----- go/internal/formatter/formatter_test.go | 6 +- go/internal/vserverclient/client.go | 9 ++- 7 files changed, 212 insertions(+), 27 deletions(-) create mode 100644 .changes/next-release/feature-output-kv9v6h8p.json create mode 100644 go/internal/formatter/color.go create mode 100644 go/internal/formatter/color_test.go diff --git a/.changes/next-release/feature-output-kv9v6h8p.json b/.changes/next-release/feature-output-kv9v6h8p.json new file mode 100644 index 0000000..0047720 --- /dev/null +++ b/.changes/next-release/feature-output-kv9v6h8p.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "output", + "description": "Implement the --color flag (on/off/auto, like aws): color status values (ACTIVE=green, ERROR/FAILED=red, CREATING/PENDING=yellow) in text and table output. auto colors only when stdout is a terminal and NO_COLOR is unset; JSON output is never colored" +} diff --git a/go/internal/cli/output.go b/go/internal/cli/output.go index 3469ad4..970670a 100644 --- a/go/internal/cli/output.go +++ b/go/internal/cli/output.go @@ -25,5 +25,6 @@ func Output(cmd *cobra.Command, data interface{}) error { output = "json" } - return formatter.Format(data, output, query, os.Stdout) + colorMode, _ := cmd.Flags().GetString("color") + return formatter.FormatColor(data, output, query, os.Stdout, formatter.ColorEnabled(colorMode, os.Stdout)) } diff --git a/go/internal/formatter/color.go b/go/internal/formatter/color.go new file mode 100644 index 0000000..3978ce9 --- /dev/null +++ b/go/internal/formatter/color.go @@ -0,0 +1,74 @@ +package formatter + +import ( + "io" + "os" + "strings" +) + +// ANSI SGR codes used for status coloring. +const ( + ansiReset = "\033[0m" + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" +) + +// statusColors maps an upper-cased status value to an ANSI color. Values are +// the lifecycle/health states VKS and vServer return; anything not listed is +// left uncolored. +var statusColors = map[string]string{ + // Healthy / done — green. + "ACTIVE": ansiGreen, "RUNNING": ansiGreen, "READY": ansiGreen, + "AVAILABLE": ansiGreen, "HEALTHY": ansiGreen, "SUCCEEDED": ansiGreen, + "COMPLETED": ansiGreen, + // In progress — yellow. + "CREATING": ansiYellow, "DELETING": ansiYellow, "UPDATING": ansiYellow, + "UPGRADING": ansiYellow, "PENDING": ansiYellow, "PROVISIONING": ansiYellow, + "RECONCILING": ansiYellow, + // Failure — red. + "ERROR": ansiRed, "FAILED": ansiRed, "UNHEALTHY": ansiRed, "DEGRADED": ansiRed, +} + +// ColorEnabled decides whether output should be colored, mirroring the AWS CLI +// --color flag: "on" always, "off" never, "auto" (or empty) only when w is a +// terminal. Honors the NO_COLOR convention in auto mode. +func ColorEnabled(mode string, w io.Writer) bool { + switch mode { + case "on": + return true + case "off": + return false + default: // "auto" or unset + if os.Getenv("NO_COLOR") != "" { + return false + } + return isTerminal(w) + } +} + +func isTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + fi, err := f.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +// colorCell wraps an already-padded cell in an ANSI color when color is enabled +// and raw is a recognized status. Padding is done by the caller on the raw text +// so column widths stay correct — the ANSI codes are zero-width and added +// around the padded string. +func colorCell(padded, raw string, color bool) string { + if !color { + return padded + } + if code, ok := statusColors[strings.ToUpper(strings.TrimSpace(raw))]; ok { + return code + padded + ansiReset + } + return padded +} diff --git a/go/internal/formatter/color_test.go b/go/internal/formatter/color_test.go new file mode 100644 index 0000000..a56569d --- /dev/null +++ b/go/internal/formatter/color_test.go @@ -0,0 +1,70 @@ +package formatter + +import ( + "bytes" + "strings" + "testing" +) + +func TestColorEnabledModes(t *testing.T) { + var buf bytes.Buffer // not an *os.File -> isTerminal() false + if !ColorEnabled("on", &buf) { + t.Error("on should force color even for a non-terminal writer") + } + if ColorEnabled("off", &buf) { + t.Error("off should never color") + } + if ColorEnabled("auto", &buf) { + t.Error("auto should be off when the writer is not a terminal") + } + if ColorEnabled("", &buf) { + t.Error("empty (auto) should be off for a non-terminal writer") + } +} + +func TestColorEnabledNoColorEnv(t *testing.T) { + t.Setenv("NO_COLOR", "1") + // on is explicit and ignores NO_COLOR; auto honors it. + if !ColorEnabled("on", &bytes.Buffer{}) { + t.Error("on should still color even with NO_COLOR set") + } + if ColorEnabled("auto", &bytes.Buffer{}) { + t.Error("auto should be off when NO_COLOR is set") + } +} + +// With color on, a status value in table output is wrapped in ANSI codes, and +// column alignment is preserved (padding is applied to the raw text). +func TestFormatTableColorsStatus(t *testing.T) { + data := map[string]interface{}{"id": "c1", "status": "ACTIVE"} + var buf bytes.Buffer + formatTable(data, &buf, true) + out := buf.String() + + if !strings.Contains(out, ansiGreen+"ACTIVE") { + t.Errorf("ACTIVE should be green-wrapped, got:\n%q", out) + } + if !strings.Contains(out, ansiReset) { + t.Errorf("colored output should reset, got:\n%q", out) + } +} + +// With color off, output is plain (no ANSI codes) — unchanged from before. +func TestFormatTableNoColorByDefault(t *testing.T) { + data := map[string]interface{}{"id": "c1", "status": "ERROR"} + var buf bytes.Buffer + formatTable(data, &buf, false) + if strings.Contains(buf.String(), "\033[") { + t.Errorf("color off should emit no ANSI codes, got:\n%q", buf.String()) + } +} + +// Non-status values are never colored, even with color on. +func TestFormatTableLeavesNonStatusUncolored(t *testing.T) { + data := map[string]interface{}{"id": "c1", "name": "demo"} + var buf bytes.Buffer + formatTable(data, &buf, true) + if strings.Contains(buf.String(), "\033[") { + t.Errorf("no recognized status -> no color, got:\n%q", buf.String()) + } +} diff --git a/go/internal/formatter/formatter.go b/go/internal/formatter/formatter.go index 20dcc5b..cfc4db1 100644 --- a/go/internal/formatter/formatter.go +++ b/go/internal/formatter/formatter.go @@ -17,8 +17,15 @@ import ( // mistaken for the rows — which previously produced empty table/text output. var knownListKeys = []string{"items", "listData", "data"} -// Format formats and outputs the response data. +// Format formats and outputs the response data (no coloring). func Format(data interface{}, outputFormat, query string, w io.Writer) error { + return FormatColor(data, outputFormat, query, w, false) +} + +// FormatColor formats and outputs the response data, coloring status values in +// text/table output when color is true. JSON output is never colored so it +// stays valid. +func FormatColor(data interface{}, outputFormat, query string, w io.Writer, color bool) error { if w == nil { w = os.Stdout } @@ -40,9 +47,9 @@ func Format(data interface{}, outputFormat, query string, w io.Writer) error { case "json": formatJSON(data, w) case "text": - formatText(data, w) + formatText(data, w, color) case "table": - formatTable(data, w) + formatTable(data, w, color) default: formatJSON(data, w) } @@ -61,7 +68,7 @@ func formatJSON(data interface{}, w io.Writer) { fmt.Fprintf(w, "%s\n", string(out)) } -func formatText(data interface{}, w io.Writer) { +func formatText(data interface{}, w io.Writer, color bool) { if data == nil || isEmptyMap(data) { return } @@ -69,7 +76,7 @@ func formatText(data interface{}, w io.Writer) { if rows, ok := listRows(data); ok { for _, item := range rows { if m, ok := item.(map[string]interface{}); ok { - fmt.Fprintln(w, strings.Join(mapValues(m), "\t")) + fmt.Fprintln(w, strings.Join(colorValues(mapValues(m), color), "\t")) } else { fmt.Fprintln(w, fmt.Sprint(item)) } @@ -78,13 +85,26 @@ func formatText(data interface{}, w io.Writer) { } if m, ok := data.(map[string]interface{}); ok { - fmt.Fprintln(w, strings.Join(mapValues(m), "\t")) + fmt.Fprintln(w, strings.Join(colorValues(mapValues(m), color), "\t")) return } fmt.Fprintln(w, fmt.Sprint(data)) } -func formatTable(data interface{}, w io.Writer) { +// colorValues colors each value that is a recognized status. Values are not +// padded here, so the raw value doubles as both the padded and raw argument. +func colorValues(vals []string, color bool) []string { + if !color { + return vals + } + out := make([]string, len(vals)) + for i, v := range vals { + out[i] = colorCell(v, v, color) + } + return out +} + +func formatTable(data interface{}, w io.Writer, color bool) { if data == nil || isEmptyMap(data) { return } @@ -92,14 +112,14 @@ func formatTable(data interface{}, w io.Writer) { // List response (top-level array, or object wrapping items/listData/data): // render as a multi-column table. if rows, ok := listRows(data); ok { - formatRowsTable(rows, w) + formatRowsTable(rows, w, color) return } // Detail response (a single object): render as a two-column key/value table // so its scalar fields are visible instead of being hijacked by a nested array. if m, ok := data.(map[string]interface{}); ok { - formatKeyValueTable(m, w) + formatKeyValueTable(m, w, color) return } @@ -109,7 +129,7 @@ func formatTable(data interface{}, w io.Writer) { // formatRowsTable renders a slice of object rows as a multi-column table with a // header row. Columns are the sorted keys of the first row. Non-map rows are // printed one per line. -func formatRowsTable(rows []interface{}, w io.Writer) { +func formatRowsTable(rows []interface{}, w io.Writer, color bool) { if len(rows) == 0 { return } @@ -143,13 +163,13 @@ func formatRowsTable(rows []interface{}, w io.Writer) { printRow(w, headers, colWidths) printSeparator(w, colWidths) for _, row := range strRows { - printRow(w, row, colWidths) + printColoredRow(w, row, colWidths, color) } } // formatKeyValueTable renders a single object as a two-column FIELD | VALUE // table, with fields sorted for deterministic output. -func formatKeyValueTable(m map[string]interface{}, w io.Writer) { +func formatKeyValueTable(m map[string]interface{}, w io.Writer, color bool) { if len(m) == 0 { return } @@ -173,7 +193,7 @@ func formatKeyValueTable(m map[string]interface{}, w io.Writer) { printRow(w, []string{"FIELD", "VALUE"}, widths) printSeparator(w, widths) for i, k := range keys { - printRow(w, []string{k, vals[i]}, widths) + printColoredRow(w, []string{k, vals[i]}, widths, color) } } @@ -185,6 +205,16 @@ func printRow(w io.Writer, cells []string, widths []int) { fmt.Fprintln(w, strings.Join(parts, " | ")) } +// printColoredRow is printRow that colors any cell whose value is a recognized +// status. Cells are padded on their raw text first, so alignment is unaffected. +func printColoredRow(w io.Writer, cells []string, widths []int, color bool) { + parts := make([]string, len(cells)) + for i, c := range cells { + parts[i] = colorCell(padRight(c, widths[i]), c, color) + } + fmt.Fprintln(w, strings.Join(parts, " | ")) +} + func printSeparator(w io.Writer, widths []int) { parts := make([]string, len(widths)) for i, width := range widths { @@ -241,6 +271,12 @@ func padRight(s string, n int) string { return s + strings.Repeat(" ", n-len(s)) } func FormatTableWithColumns(data interface{}, columns []string, query string, w io.Writer) error { + return FormatTableWithColumnsColor(data, columns, query, w, false) +} + +// FormatTableWithColumnsColor is FormatTableWithColumns that colors status +// values when color is true. +func FormatTableWithColumnsColor(data interface{}, columns []string, query string, w io.Writer, color bool) error { if w == nil { w = os.Stdout } @@ -254,11 +290,11 @@ func FormatTableWithColumns(data interface{}, columns []string, query string, w if data == nil { return nil } - formatTableColumns(data, columns, w) + formatTableColumns(data, columns, w, color) return nil } -func formatTableColumns(data interface{}, columns []string, w io.Writer) { +func formatTableColumns(data interface{}, columns []string, w io.Writer, color bool) { if data == nil || isEmptyMap(data) { return } @@ -306,11 +342,7 @@ func formatTableColumns(data interface{}, columns []string, w io.Writer) { fmt.Fprintln(w, strings.Join(sepParts, "-+-")) for _, row := range strRows { - parts := make([]string, len(row)) - for i, val := range row { - parts[i] = padRight(val, colWidths[i]) - } - fmt.Fprintln(w, strings.Join(parts, " | ")) + printColoredRow(w, row, colWidths, color) } } diff --git a/go/internal/formatter/formatter_test.go b/go/internal/formatter/formatter_test.go index 46e9b18..5c68dcb 100644 --- a/go/internal/formatter/formatter_test.go +++ b/go/internal/formatter/formatter_test.go @@ -16,7 +16,7 @@ func TestFormatTableDetailObject(t *testing.T) { "listSubnetIds": []interface{}{}, // empty nested array — must NOT blank the output } var buf bytes.Buffer - formatTable(data, &buf) + formatTable(data, &buf, false) out := buf.String() if strings.TrimSpace(out) == "" { @@ -40,7 +40,7 @@ func TestFormatTableListResponse(t *testing.T) { "total": float64(2), } var buf bytes.Buffer - formatTable(data, &buf) + formatTable(data, &buf, false) out := buf.String() for _, want := range []string{"id", "name", "c1", "alpha", "c2", "beta"} { @@ -61,7 +61,7 @@ func TestFormatTableTopLevelArray(t *testing.T) { map[string]interface{}{"uuid": "s2"}, } var buf bytes.Buffer - formatTable(data, &buf) + formatTable(data, &buf, false) out := buf.String() for _, want := range []string{"uuid", "s1", "s2"} { if !strings.Contains(out, want) { diff --git a/go/internal/vserverclient/client.go b/go/internal/vserverclient/client.go index d220c18..f93f000 100644 --- a/go/internal/vserverclient/client.go +++ b/go/internal/vserverclient/client.go @@ -76,7 +76,8 @@ func Output(cmd *cobra.Command, cfg *config.Config, data interface{}) error { output = "json" } - return formatter.Format(data, output, query, os.Stdout) + colorMode, _ := cmd.Flags().GetString("color") + return formatter.FormatColor(data, output, query, os.Stdout, formatter.ColorEnabled(colorMode, os.Stdout)) } // OutputWithColumns formats and writes the API result to stdout. @@ -92,8 +93,10 @@ func OutputWithColumns(cmd *cobra.Command, cfg *config.Config, data interface{}, output = "json" } + colorMode, _ := cmd.Flags().GetString("color") + color := formatter.ColorEnabled(colorMode, os.Stdout) if output == "table" && len(columns) > 0 { - return formatter.FormatTableWithColumns(data, columns, query, os.Stdout) + return formatter.FormatTableWithColumnsColor(data, columns, query, os.Stdout, color) } - return formatter.Format(data, output, query, os.Stdout) + return formatter.FormatColor(data, output, query, os.Stdout, color) }