160 lines
4.0 KiB
Go
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]) + "…"
|
|
}
|