banger/internal/cli/errors.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

90 lines
2.9 KiB
Go

package cli
import (
"errors"
"strings"
"banger/internal/cli/style"
"banger/internal/rpc"
"io"
)
// TranslateError is the public entry point used by cmd/banger/main.go
// to render any error reaching the top of the cobra tree. Forwards
// to the package-internal helper so tests can reach it directly.
func TranslateError(w io.Writer, err error) string {
return translateRPCError(w, err)
}
// translateRPCError turns an error returned by rpc.Call into a
// user-facing string. Known codes get short, friendly prefixes;
// unknown codes pass through verbatim so debuggability is preserved.
// When the daemon attached an op_id the helper appends it in parens
// so an operator can paste it into journalctl --grep.
//
// Color is applied only when w is a TTY (and NO_COLOR is unset).
// The returned string never includes a trailing newline — caller
// chooses where it goes.
func translateRPCError(w io.Writer, err error) string {
if err == nil {
return ""
}
var rpcErr *rpc.ErrorResponse
if !errors.As(err, &rpcErr) || rpcErr == nil {
// Non-RPC failures (dialing the socket, decode errors,
// context cancellation, ...) come through as plain Go
// errors. Surface them verbatim — they already mention
// the underlying cause clearly enough.
return err.Error()
}
prefix := errorCodePrefix(rpcErr.Code)
body := rpcErr.Message
if prefix != "" {
body = prefix + ": " + rpcErr.Message
} else if rpcErr.Message == "" {
// Defensive: a server that returned a code with no
// message still has SOMETHING to report; default to the
// raw code so we never print an empty error.
body = rpcErr.Code
}
if rpcErr.OpID != "" {
body = body + " (" + style.Dim(w, rpcErr.OpID) + ")"
}
return body
}
// errorCodePrefix maps the small set of codes the daemon emits to
// short user-facing labels. Unknown codes return "" so the message
// alone is shown — keeps the door open for future codes the CLI
// hasn't been updated to recognise.
//
// "operation_failed" is the catch-all the generic dispatcher uses
// when a service returned an error; the message is already self-
// explanatory, so we strip the code entirely. Specialised codes
// (not_found, already_exists, ...) keep a label because the
// message body alone may not say what kind of failure it is.
func errorCodePrefix(code string) string {
switch strings.TrimSpace(code) {
case "", "operation_failed":
return ""
case "not_found":
return "not found"
case "not_running":
return "not running"
case "already_exists":
return "already exists"
case "bad_request", "bad_params":
return "bad request"
case "bad_version":
return "version mismatch"
case "unauthorized":
return "unauthorized"
case "unknown_method":
return "unknown method"
default:
// Surface the raw code so an operator filing a bug has
// something concrete to grep for. Strips the boilerplate
// "operation_failed" but keeps anything novel.
return code
}
}