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.
205 lines
6.8 KiB
Go
205 lines
6.8 KiB
Go
// 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))
|
|
}
|