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) }