Introduce internal/ui package and rewrite user-facing output
All user-facing output now goes through a single ui.Writer with a
uniform style:
》 (white) for begin / info / notice
》 (green) for complete / success
Warning: for warnings (orange)
ERROR: for errors (red)
》 (indented) for progress heartbeats
Color is enabled when stdout is a TTY and NO_COLOR is unset.
Standards:
- Complete-sentence messages with fully qualified terms ("backup
destination store", "local index database", "snapshot source
files enumeration").
- Every Complete has a matching Begin.
- Natural verb tense conveys state ("Uploading" -> "Uploaded"). The
words "begin"/"complete" never appear in message bodies; the marker
color carries that information.
- ETA means clock time, not duration. Progress lines say "estimated
remaining time (<dur>), finish at <time>" with both labeled.
Adds globals.CommitDate (populated by Makefile/Dockerfile/goreleaser
via ldflags from `git show -s --format=%cI HEAD`) and a startup banner
printed once per invocation.
Strips fx call-chain noise from startup errors so users see the actual
underlying error (e.g. "creating base path: mkdir /Volumes/BACKUPS:
permission denied" instead of three layers of "could not build
arguments for function ...").
README documents the output style and the ui package conventions.
This commit is contained in:
204
internal/ui/ui.go
Normal file
204
internal/ui/ui.go
Normal file
@@ -0,0 +1,204 @@
|
||||
// Package ui provides consistent user-facing output formatting for vaultik.
|
||||
// All status updates, banners, errors, and warnings printed to the user
|
||||
// should go through a *Writer from this package.
|
||||
//
|
||||
// Message classes (see Writer methods):
|
||||
//
|
||||
// - Begin — operation start, left-aligned, marker "》" (white)
|
||||
// - Complete— operation completion, left-aligned, marker "》" (green)
|
||||
// - Info — left-aligned neutral status, marker "》" (white)
|
||||
// - Notice — left-aligned important note, marker "》" (cyan)
|
||||
// - Warning — left-aligned warning, full word "Warning: " (orange/yellow)
|
||||
// - Error — left-aligned error, full word "ERROR: " (red)
|
||||
// - Progress— indented heartbeat / per-item update, marker " 》" (white)
|
||||
// - Banner — application banner line, left-aligned, no marker
|
||||
//
|
||||
// Value formatters (Hex, Size, Duration, Time, Path, Snapshot, Speed,
|
||||
// Count, Percent) return ANSI-colored strings the caller composes into
|
||||
// the message body. When color is disabled (non-TTY output or NO_COLOR
|
||||
// set) all formatters return plain text.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// ANSI SGR escape sequences.
|
||||
const (
|
||||
ansiReset = "\033[0m"
|
||||
ansiBold = "\033[1m"
|
||||
ansiRed = "\033[31m"
|
||||
ansiGreen = "\033[32m"
|
||||
ansiYellow = "\033[33m" // used for orange "Warning:" and for durations
|
||||
ansiBlue = "\033[34m"
|
||||
ansiMagenta = "\033[35m"
|
||||
ansiCyan = "\033[36m"
|
||||
ansiWhite = "\033[37m"
|
||||
)
|
||||
|
||||
// Marker is the chevron prefix used for all non-error/warning lines.
|
||||
const Marker = "》"
|
||||
|
||||
// Writer formats and emits user-facing messages with optional ANSI color.
|
||||
type Writer struct {
|
||||
out io.Writer
|
||||
color bool
|
||||
}
|
||||
|
||||
// New returns a Writer that emits to out. Color is enabled when out is a
|
||||
// TTY and the NO_COLOR environment variable is unset.
|
||||
// https://no-color.org/
|
||||
func New(out io.Writer) *Writer {
|
||||
return &Writer{out: out, color: shouldColor(out)}
|
||||
}
|
||||
|
||||
// NewWithColor returns a Writer with an explicit color setting, ignoring
|
||||
// TTY detection. Useful for tests and for piped output that the caller
|
||||
// wants to colorize anyway.
|
||||
func NewWithColor(out io.Writer, color bool) *Writer {
|
||||
return &Writer{out: out, color: color}
|
||||
}
|
||||
|
||||
// Out returns the underlying writer.
|
||||
func (w *Writer) Out() io.Writer { return w.out }
|
||||
|
||||
// Color reports whether color is enabled on this writer.
|
||||
func (w *Writer) Color() bool { return w.color }
|
||||
|
||||
// shouldColor returns true when w is a real TTY and NO_COLOR is unset.
|
||||
func shouldColor(w io.Writer) bool {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
f, ok := w.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
// paint wraps s in the given ANSI color when color is enabled.
|
||||
func (w *Writer) paint(color, s string) string {
|
||||
if !w.color {
|
||||
return s
|
||||
}
|
||||
return color + s + ansiReset
|
||||
}
|
||||
|
||||
// ───────────────────────── message methods ─────────────────────────
|
||||
|
||||
// Begin prints an operation-start line, left-aligned with a white marker.
|
||||
func (w *Writer) Begin(format string, args ...any) {
|
||||
w.emit(ansiWhite, Marker, "", format, args)
|
||||
}
|
||||
|
||||
// Complete prints an operation-completion line in green, left-aligned.
|
||||
func (w *Writer) Complete(format string, args ...any) {
|
||||
w.emit(ansiGreen, Marker, ansiGreen, format, args)
|
||||
}
|
||||
|
||||
// Info prints a neutral status line, left-aligned with a white marker.
|
||||
func (w *Writer) Info(format string, args ...any) {
|
||||
w.emit(ansiWhite, Marker, "", format, args)
|
||||
}
|
||||
|
||||
// Notice prints an attention-worthy informational line, marker in cyan.
|
||||
func (w *Writer) Notice(format string, args ...any) {
|
||||
w.emit(ansiCyan, Marker, "", format, args)
|
||||
}
|
||||
|
||||
// Warning prints "Warning: " in orange/yellow followed by the message.
|
||||
func (w *Writer) Warning(format string, args ...any) {
|
||||
prefix := w.paint(ansiYellow+ansiBold, "Warning: ")
|
||||
_, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Error prints "ERROR: " in red followed by the message. Goes to the same
|
||||
// writer as everything else; callers that want stderr should construct a
|
||||
// separate Writer for it.
|
||||
func (w *Writer) Error(format string, args ...any) {
|
||||
prefix := w.paint(ansiRed+ansiBold, "ERROR: ")
|
||||
_, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Progress prints an indented heartbeat / per-item update, marker in white.
|
||||
func (w *Writer) Progress(format string, args ...any) {
|
||||
w.emit(ansiWhite, " "+Marker, "", format, args)
|
||||
}
|
||||
|
||||
// Banner prints a line with no marker, left-aligned. Used for the
|
||||
// application startup banner only.
|
||||
func (w *Writer) Banner(format string, args ...any) {
|
||||
_, _ = fmt.Fprintln(w.out, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// emit writes "<prefix> <body>\n" with the prefix painted in prefixColor
|
||||
// and the body optionally painted in bodyColor (empty = no body color).
|
||||
func (w *Writer) emit(prefixColor, prefix, bodyColor, format string, args []any) {
|
||||
body := fmt.Sprintf(format, args...)
|
||||
if bodyColor != "" {
|
||||
body = w.paint(bodyColor, body)
|
||||
}
|
||||
_, _ = fmt.Fprintln(w.out, w.paint(prefixColor, prefix)+" "+body)
|
||||
}
|
||||
|
||||
// ───────────────────────── value formatters ─────────────────────────
|
||||
//
|
||||
// These return ANSI-colored strings the caller composes into a message
|
||||
// body. When color is disabled they return plain text.
|
||||
|
||||
// Hex colorizes a hex identifier (blob hash, chunk hash, snapshot id).
|
||||
// Long hashes are abbreviated to first 12 chars with "...".
|
||||
func (w *Writer) Hex(s string) string {
|
||||
short := s
|
||||
if len(s) > 12 {
|
||||
short = s[:12] + "..."
|
||||
}
|
||||
return w.paint(ansiCyan, short)
|
||||
}
|
||||
|
||||
// Snapshot colorizes a snapshot ID (full, no abbreviation).
|
||||
func (w *Writer) Snapshot(id string) string {
|
||||
return w.paint(ansiCyan+ansiBold, id)
|
||||
}
|
||||
|
||||
// Path colorizes a filesystem path.
|
||||
func (w *Writer) Path(p string) string {
|
||||
return w.paint(ansiBlue, p)
|
||||
}
|
||||
|
||||
// Size colorizes a byte count using humanize.Bytes.
|
||||
func (w *Writer) Size(bytes int64) string {
|
||||
return w.paint(ansiMagenta, humanize.Bytes(uint64(bytes)))
|
||||
}
|
||||
|
||||
// Speed colorizes a byte-per-second value as "<size>/sec".
|
||||
func (w *Writer) Speed(bytesPerSec float64) string {
|
||||
return w.paint(ansiMagenta, humanize.Bytes(uint64(bytesPerSec))+"/sec")
|
||||
}
|
||||
|
||||
// Duration colorizes a time.Duration rounded to the nearest second.
|
||||
func (w *Writer) Duration(d time.Duration) string {
|
||||
return w.paint(ansiYellow, d.Round(time.Second).String())
|
||||
}
|
||||
|
||||
// Time colorizes an absolute time (RFC3339, second precision).
|
||||
func (w *Writer) Time(t time.Time) string {
|
||||
return w.paint(ansiYellow, t.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Count colorizes an integer count with thousands separators.
|
||||
func (w *Writer) Count(n int) string {
|
||||
return w.paint(ansiMagenta, humanize.Comma(int64(n)))
|
||||
}
|
||||
|
||||
// Percent colorizes a 0..100 percentage.
|
||||
func (w *Writer) Percent(p float64) string {
|
||||
return w.paint(ansiMagenta, fmt.Sprintf("%.1f%%", p))
|
||||
}
|
||||
86
internal/ui/ui_test.go
Normal file
86
internal/ui/ui_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestWriter(color bool) (*Writer, *bytes.Buffer) {
|
||||
buf := &bytes.Buffer{}
|
||||
return NewWithColor(buf, color), buf
|
||||
}
|
||||
|
||||
func TestMessageMethodsPlain(t *testing.T) {
|
||||
tests := []struct {
|
||||
method string
|
||||
fn func(*Writer)
|
||||
want string
|
||||
}{
|
||||
{"Begin", func(w *Writer) { w.Begin("starting %s", "thing") }, "》 starting thing\n"},
|
||||
{"Complete", func(w *Writer) { w.Complete("done %s", "thing") }, "》 done thing\n"},
|
||||
{"Info", func(w *Writer) { w.Info("status") }, "》 status\n"},
|
||||
{"Notice", func(w *Writer) { w.Notice("note") }, "》 note\n"},
|
||||
{"Warning", func(w *Writer) { w.Warning("oops") }, "Warning: oops\n"},
|
||||
{"Error", func(w *Writer) { w.Error("boom") }, "ERROR: boom\n"},
|
||||
{"Progress", func(w *Writer) { w.Progress("p") }, " 》 p\n"},
|
||||
{"Banner", func(w *Writer) { w.Banner("hello") }, "hello\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method, func(t *testing.T) {
|
||||
w, buf := newTestWriter(false)
|
||||
tt.fn(w)
|
||||
if got := buf.String(); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorOutputContainsANSI(t *testing.T) {
|
||||
w, buf := newTestWriter(true)
|
||||
w.Error("boom")
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "\033[") {
|
||||
t.Errorf("expected ANSI escapes in color output, got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "ERROR: ") {
|
||||
t.Errorf("expected 'ERROR: ' text in output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueFormattersPlain(t *testing.T) {
|
||||
w, _ := newTestWriter(false)
|
||||
|
||||
if got := w.Hex("0123456789abcdef0123"); got != "0123456789ab..." {
|
||||
t.Errorf("Hex long: got %q", got)
|
||||
}
|
||||
if got := w.Hex("short"); got != "short" {
|
||||
t.Errorf("Hex short: got %q", got)
|
||||
}
|
||||
if got := w.Size(1024); got != "1.0 kB" {
|
||||
t.Errorf("Size: got %q", got)
|
||||
}
|
||||
if got := w.Duration(90 * time.Second); got != "1m30s" {
|
||||
t.Errorf("Duration: got %q", got)
|
||||
}
|
||||
if got := w.Count(12345); got != "12,345" {
|
||||
t.Errorf("Count: got %q", got)
|
||||
}
|
||||
if got := w.Percent(12.34); got != "12.3%" {
|
||||
t.Errorf("Percent: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueFormattersColored(t *testing.T) {
|
||||
w, _ := newTestWriter(true)
|
||||
hex := w.Hex("0123456789abcdef0123")
|
||||
if !strings.Contains(hex, "\033[") {
|
||||
t.Errorf("expected ANSI in colored Hex output, got %q", hex)
|
||||
}
|
||||
if !strings.Contains(hex, "0123456789ab") {
|
||||
t.Errorf("expected hex content in output, got %q", hex)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user