Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/next-release/feature-output-kv9v6h8p.json
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 2 additions & 1 deletion go/internal/cli/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
74 changes: 74 additions & 0 deletions go/internal/formatter/color.go
Original file line number Diff line number Diff line change
@@ -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
}
70 changes: 70 additions & 0 deletions go/internal/formatter/color_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
72 changes: 52 additions & 20 deletions go/internal/formatter/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
Expand All @@ -61,15 +68,15 @@ 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
}

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))
}
Expand All @@ -78,28 +85,41 @@ 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
}

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

Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
6 changes: 3 additions & 3 deletions go/internal/formatter/formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) == "" {
Expand All @@ -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"} {
Expand All @@ -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) {
Expand Down
9 changes: 6 additions & 3 deletions go/internal/vserverclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
Loading