banger/internal/cli/style/style.go
Thales Maciel 71a332a6a1
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>
2026-04-26 22:27:07 -03:00

70 lines
2 KiB
Go

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