Files
pages/internal/cliutil/output.go
T
2026-08-15 07:13:00 +00:00

160 lines
4.0 KiB
Go

package cliutil
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"text/tabwriter"
"time"
)
// Output formats.
const (
FormatTable = "table"
FormatJSON = "json"
)
// ValidFormat reports whether s names an output format.
func ValidFormat(s string) bool { return s == FormatTable || s == FormatJSON }
// Printer writes command output in the format the user asked for.
type Printer struct {
Out io.Writer
Format string
}
// Print renders v. In table format it calls table to build the rendering; a nil
// table means the value has no tabular form and JSON is used regardless.
func (p *Printer) Print(v any, table func() *Table) error {
if p.Format == FormatJSON || table == nil {
return p.JSON(v)
}
return table().Write(p.Out)
}
// JSON writes v as indented JSON with a trailing newline.
func (p *Printer) JSON(v any) error {
enc := json.NewEncoder(p.Out)
enc.SetIndent("", " ")
// The output is read by humans and by jq, neither of which wants &, < and >
// spelled as & and friends.
enc.SetEscapeHTML(false)
return enc.Encode(v)
}
// Printf writes a human-readable line, and nothing at all in JSON format —
// progress chatter must never end up in a stream something is parsing.
func (p *Printer) Printf(format string, args ...any) {
if p.Format == FormatJSON {
return
}
fmt.Fprintf(p.Out, format, args...)
}
// Table is a column-aligned rendering built up row by row.
type Table struct {
header []string
rows [][]string
}
// NewTable starts a table with the given column headings.
func NewTable(header ...string) *Table { return &Table{header: header} }
// Row appends a row. Cells beyond the header count are kept: a ragged table is
// a formatting annoyance, not a reason to drop data.
func (t *Table) Row(cells ...string) { t.rows = append(t.rows, cells) }
// Len reports how many rows have been added.
func (t *Table) Len() int { return len(t.rows) }
// Write renders the table. An empty table prints its header only, so a caller
// can tell "no rows" from "the command did nothing".
func (t *Table) Write(w io.Writer) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if len(t.header) > 0 {
fmt.Fprintln(tw, strings.Join(t.header, "\t"))
}
for _, row := range t.rows {
fmt.Fprintln(tw, strings.Join(row, "\t"))
}
return tw.Flush()
}
// ------------------------------------------------------------ cell helpers
// Dash is what an empty cell shows, so a missing value is visibly missing
// rather than looking like a column-alignment mistake.
const Dash = "-"
// Str renders a string cell, showing Dash when empty.
func Str(s string) string {
if s == "" {
return Dash
}
return s
}
// Bool renders a boolean cell.
func Bool(b bool) string {
if b {
return "yes"
}
return "no"
}
// Plural renders a count with its noun, adding "s" for anything but one.
// Only regular nouns are pluralized correctly, which is all this CLI has.
func Plural(n int, noun string) string {
if n == 1 {
return "1 " + noun
}
return strconv.Itoa(n) + " " + noun + "s"
}
// Time renders an absolute local timestamp to the second. Absolute rather than
// relative: these values go into CI logs that are read days later, where "2
// hours ago" has lost its reference point.
func Time(t time.Time) string {
if t.IsZero() {
return Dash
}
return t.Local().Format("2006-01-02 15:04:05")
}
// TimePtr renders an optional timestamp.
func TimePtr(t *time.Time) string {
if t == nil {
return Dash
}
return Time(*t)
}
// Bytes renders a byte count in binary units, as a human reads it.
func Bytes(n int64) string {
const unit = 1024
if n < unit {
return strconv.FormatInt(n, 10) + " B"
}
div, exp := int64(unit), 0
for n/div >= unit && exp < 4 {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTP"[exp])
}
// Truncate shortens s to at most max runes, marking the cut with an ellipsis so
// a clipped value is never mistaken for a complete one.
func Truncate(s string, max int) string {
if max <= 1 {
return s
}
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max-1]) + "…"
}