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]) + "…" }