// 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 " \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 "/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)) }