cli: maturity polish — color, error translation, tabwriter consistency
Adds three small but high-leverage presentation tweaks for v0.1: 1. internal/cli/style is a new ~70 LOC package with Pass/Fail/Warn/ Dim/Bold helpers. Each is TTY-gated and obeys NO_COLOR. No external dep. Wired into the doctor PASS/FAIL/WARN status, the "banger:" error prefix on stderr, and the dim 'ready in <elapsed>' line. 2. internal/cli/errors translates rpc.ErrorResponse into user-facing text. operation_failed becomes invisible (the message wins); not_found, already_exists, bad_request, bad_version, unauthorized, unknown_method get short labels; unknown codes pass through. The daemon-attached op_id lands in dim parens — paste into journalctl --grep to find the daemon log line that produced the failure. 3. Tabwriter config converges on (0, 8, 2, ' ', 0) across every list/table command. The vm prune confirmation table picked up the right config; system install + system status switched from bare "key: value\n" lines to tabular form. printVMSpecLine drops its Unicode middle dot for an ASCII '|' so terminals without UTF-8 render cleanly. Tests cover translateRPCError for every code, style helpers no-op on non-TTY and under NO_COLOR. Smoke status greps switch from "key: value" to "key value" to match the new format. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e47b8146dc
commit
71a332a6a1
11 changed files with 358 additions and 28 deletions
70
internal/cli/style/style.go
Normal file
70
internal/cli/style/style.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// Package style provides a tiny, conservative ANSI-color helper for
|
||||
// banger's CLI. The contract:
|
||||
//
|
||||
// - Each helper takes the writer the styled string is going to and
|
||||
// returns either the wrapped string or the plain one.
|
||||
// - "Wrapped" only happens when the writer is a TTY AND the
|
||||
// NO_COLOR environment variable is unset.
|
||||
// - No 256-color or truecolor; no theme system; no external dep.
|
||||
//
|
||||
// Banger's CLI uses these for status (pass/fail/warn), error
|
||||
// prefixes, and dim secondary text. Anything richer belongs in a
|
||||
// dedicated TUI layer that this package isn't.
|
||||
package style
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ANSI escape sequences. Kept private — callers compose meaning via
|
||||
// the named helpers (Pass/Fail/Warn/...), not raw codes.
|
||||
const (
|
||||
ansiReset = "\x1b[0m"
|
||||
ansiBold = "\x1b[1m"
|
||||
ansiDim = "\x1b[2m"
|
||||
ansiRed = "\x1b[31m"
|
||||
ansiGreen = "\x1b[32m"
|
||||
ansiYel = "\x1b[33m"
|
||||
)
|
||||
|
||||
// Pass wraps s in green when w is a TTY and NO_COLOR is unset.
|
||||
func Pass(w io.Writer, s string) string { return wrap(w, ansiGreen, s) }
|
||||
|
||||
// Fail wraps s in red.
|
||||
func Fail(w io.Writer, s string) string { return wrap(w, ansiRed, s) }
|
||||
|
||||
// Warn wraps s in yellow.
|
||||
func Warn(w io.Writer, s string) string { return wrap(w, ansiYel, s) }
|
||||
|
||||
// Dim wraps s in dim.
|
||||
func Dim(w io.Writer, s string) string { return wrap(w, ansiDim, s) }
|
||||
|
||||
// Bold wraps s in bold.
|
||||
func Bold(w io.Writer, s string) string { return wrap(w, ansiBold, s) }
|
||||
|
||||
// SupportsColor reports whether colored output should be emitted to
|
||||
// w. Exposed so callers that build multi-segment strings can avoid
|
||||
// duplicating the gate per call.
|
||||
func SupportsColor(w io.Writer) bool {
|
||||
if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" {
|
||||
return false
|
||||
}
|
||||
file, ok := w.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
func wrap(w io.Writer, code, s string) string {
|
||||
if !SupportsColor(w) {
|
||||
return s
|
||||
}
|
||||
return code + s + ansiReset
|
||||
}
|
||||
64
internal/cli/style/style_test.go
Normal file
64
internal/cli/style/style_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package style
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStyleNoOpsForNonTTYWriter pins that styled helpers don't emit
|
||||
// ANSI escapes when the destination isn't a terminal. Buffers stand
|
||||
// in for any non-TTY writer (CI, redirected stdout, log files).
|
||||
func TestStyleNoOpsForNonTTYWriter(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cases := map[string]string{
|
||||
"pass": Pass(&buf, "ok"),
|
||||
"fail": Fail(&buf, "boom"),
|
||||
"warn": Warn(&buf, "huh"),
|
||||
"dim": Dim(&buf, "sub"),
|
||||
"bold": Bold(&buf, "bold"),
|
||||
}
|
||||
for label, got := range cases {
|
||||
if strings.Contains(got, "\x1b[") {
|
||||
t.Errorf("%s: contains ANSI escape on non-TTY writer: %q", label, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStyleSuppressedByNoColor pins https://no-color.org compliance:
|
||||
// even on a "real" TTY, NO_COLOR forces plain output.
|
||||
func TestStyleSuppressedByNoColor(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("Pipe: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
// w is a pipe end, not a char device — NO_COLOR is the dominant
|
||||
// gate but verifying the helper still suppresses guards against
|
||||
// a future TTY-detection regression that would otherwise need a
|
||||
// pty harness to surface.
|
||||
if got := Pass(w, "ok"); strings.Contains(got, "\x1b[") {
|
||||
t.Errorf("NO_COLOR set but Pass() emitted ANSI: %q", got)
|
||||
}
|
||||
if got := Fail(w, "boom"); strings.Contains(got, "\x1b[") {
|
||||
t.Errorf("NO_COLOR set but Fail() emitted ANSI: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupportsColorRespectsNoColor confirms the gate function used
|
||||
// by the helpers. Required for callers that compose multi-segment
|
||||
// strings and want to ask once.
|
||||
func TestSupportsColorRespectsNoColor(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
tmp, err := os.CreateTemp(t.TempDir(), "style-*")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
defer tmp.Close()
|
||||
if SupportsColor(tmp) {
|
||||
t.Fatal("SupportsColor returned true with NO_COLOR set")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue