Files
vaultik/internal/ui/ui.go
sneak b960ca37a8
Some checks failed
check / check (pull_request) Failing after 59s
Suppress the gosec and revive findings with no fix (closes #61)
Seven findings remain that cannot be fixed without either lying about
the code or making a repo-wide naming decision, so each carries a
per-site //nolint directive with its justification.

gosec G115 (internal/log, internal/ui): term.IsTerminal takes an int
and os.File.Fd() returns a uintptr, so the conversion is forced by the
API. A file descriptor always fits in an int on every platform Go
supports, and a closed file yields -1, which IsTerminal reports as not
a terminal.

gosec G703 (internal/vaultik/verify.go): the removed path comes from
os.CreateTemp a few lines above and never from user input. G703's taint
analysis treats every path derived from an *os.File as tainted, so
there is no code shape that clears it.

revive var-naming (internal/log, internal/crypto, internal/types):
fixing these means renaming packages across the whole codebase, which
is the repo owner's call, not a lint fix. Neither stdlib log nor stdlib
crypto is imported anywhere in the repo, so nothing is actually
shadowed today. The rename decision is tracked in issue #76. revive
reports a package-name failure only once per package directory, on
whichever file it happens to lint first, so every file of the affected
packages carries the directive and lists nolintlint alongside revive so
the ones that lose the race are not reported as unused.

With this, make check exits 0 under the canonical .golangci.yml
(sha256 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb,
unmodified), which also unblocks issue #59.

TODO.md: record this work, correct the earlier entry that claimed make
check was green when lint was still red, and move the next step on to
the stale-branch triage.
2026-08-09 01:48:57 +00:00

326 lines
10 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):
//
// - Beginf — operation start, left-aligned, marker "》" (white)
// - Completef— operation completion, left-aligned, marker "》" (green)
// - Infof — left-aligned neutral status, marker "》" (white)
// - Noticef — left-aligned important note, marker "》" (cyan)
// - Warningf — left-aligned warning, full word "Warning: " (orange/yellow)
// - Errorf — left-aligned error, full word "ERROR: " (red)
// - Progressf— indented heartbeat / per-item update, marker " 》" (white)
// - Bannerf — 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 = "》"
const (
// hexAbbrevLen is the number of hash characters Hex keeps before "...".
hexAbbrevLen = 12
// bitsPerByte converts bytes/sec into bits/sec for Speed.
bitsPerByte = 8
// SI thresholds for Speed's unit selection, in bits/sec.
gigabit = 1e9
megabit = 1e6
kilobit = 1e3
)
// Writer formats and emits user-facing messages with optional ANSI color.
// It also counts warnings and errors emitted so the caller can summarize at
// the end of an operation ("Finished successfully." vs "Finished with
// warnings.").
//
// When Quiet is set, Begin/Complete/Info/Notice/Detail/Progress/Banner
// are silently dropped, but Warning and Error always emit. This honors
// the convention that --quiet "Suppresses non-error output" — warnings
// and errors are by definition not suppressible.
type Writer struct {
out io.Writer
color bool
quiet bool
warnings int
errors int
}
// 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}
}
// SetQuiet toggles the writer's quiet mode. In quiet mode all message
// classes are silenced except Warning and Error.
func (w *Writer) SetQuiet(quiet bool) { w.quiet = quiet }
// Quiet reports whether the writer is in quiet mode.
func (w *Writer) Quiet() bool { return w.quiet }
// 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
}
// term.IsTerminal takes an int, and a file descriptor always fits in
// one on every platform Go supports; a closed file yields -1, which
// IsTerminal reports as not a terminal.
return term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: fd fits in int
}
// ───────────────────────── message methods ─────────────────────────
// Beginf prints an operation-start line, left-aligned with a white marker.
func (w *Writer) Beginf(format string, args ...any) {
if w.quiet {
return
}
w.emit(ansiWhite, Marker, "", format, args)
}
// Completef prints an operation-completion line in green, left-aligned.
func (w *Writer) Completef(format string, args ...any) {
if w.quiet {
return
}
w.emit(ansiGreen, Marker, ansiGreen, format, args)
}
// Infof prints a neutral status line, left-aligned with a white marker.
func (w *Writer) Infof(format string, args ...any) {
if w.quiet {
return
}
w.emit(ansiWhite, Marker, "", format, args)
}
// Noticef prints an attention-worthy informational line, marker in cyan.
func (w *Writer) Noticef(format string, args ...any) {
if w.quiet {
return
}
w.emit(ansiCyan, Marker, "", format, args)
}
// Warningf prints "⚠️ Warning: " in orange/yellow followed by the message.
func (w *Writer) Warningf(format string, args ...any) {
w.warnings++
prefix := "⚠️ " + w.paint(ansiYellow+ansiBold, "Warning: ")
_, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...))
}
// Errorf 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) Errorf(format string, args ...any) {
w.errors++
prefix := "🛑 " + w.paint(ansiRed+ansiBold, "ERROR: ")
_, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...))
}
// Detailf prints an indented continuation line under a preceding Completef
// (or other top-level message). Marker " 》" (white) at column 2.
// Distinct from Progressf (semantically a "heartbeat") in usage but
// visually identical.
func (w *Writer) Detailf(format string, args ...any) {
if w.quiet {
return
}
w.emit(ansiWhite, " "+Marker, "", format, args)
}
// WarningCount returns the number of Warning() calls this writer has emitted.
func (w *Writer) WarningCount() int { return w.warnings }
// ErrorCount returns the number of Error() calls this writer has emitted.
func (w *Writer) ErrorCount() int { return w.errors }
// Progressf prints an indented heartbeat / per-item update, marker in white.
func (w *Writer) Progressf(format string, args ...any) {
if w.quiet {
return
}
w.emit(ansiWhite, " "+Marker, "", format, args)
}
// Bannerf prints a line with no marker, left-aligned. Bold when color
// is enabled. Used for the application startup banner only.
func (w *Writer) Bannerf(format string, args ...any) {
if w.quiet {
return
}
body := fmt.Sprintf(format, args...)
if w.color {
body = ansiBold + body + ansiReset
}
_, _ = fmt.Fprintln(w.out, 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) > hexAbbrevLen {
short = s[:hexAbbrevLen] + "..."
}
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))) //nolint:gosec // G115: >=0
}
// Speed colorizes a network transfer rate. Input is bytes/sec; output is
// bits/sec with an appropriate SI unit (bit/s, Kbit/s, Mbit/s, Gbit/s) —
// network transfer rates are conventionally expressed in bits.
func (w *Writer) Speed(bytesPerSec float64) string {
if bytesPerSec <= 0 {
return w.paint(ansiMagenta, "N/A")
}
bitsPerSec := bytesPerSec * bitsPerByte
var s string
switch {
case bitsPerSec >= gigabit:
s = fmt.Sprintf("%.1f Gbit/sec", bitsPerSec/gigabit)
case bitsPerSec >= megabit:
s = fmt.Sprintf("%.0f Mbit/sec", bitsPerSec/megabit)
case bitsPerSec >= kilobit:
s = fmt.Sprintf("%.0f Kbit/sec", bitsPerSec/kilobit)
default:
s = fmt.Sprintf("%.0f bit/sec", bitsPerSec)
}
return w.paint(ansiMagenta, s)
}
// 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 clock time. If t falls on today's local
// calendar date the output is "HH:MM:SS"; otherwise it is
// "YYYY-MM-DD HH:MM:SS". No timezone is included — values are
// displayed in the process's local zone.
func (w *Writer) Time(t time.Time) string {
t = t.Local() //nolint:gosmopolitan // local-time display is intentional
now := time.Now()
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return w.paint(ansiYellow, t.Format("15:04:05"))
}
return w.paint(ansiYellow, t.Format("2006-01-02 15:04:05"))
}
// 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))
}
// ───────────────────────── internal helpers ─────────────────────────
// 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
}
// 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)
}