Files
plainleaf/cli/output.go
T

341 lines
8.1 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
type OutputMode int
const (
// OutputAuto picks Text on a TTY (or with --text), JSON otherwise.
// Use only at flag-resolution time; FormatOutput will not see this value.
OutputAuto OutputMode = iota
// OutputJSON is compact JSON with a trailing newline.
OutputJSON
// OutputText is the human-friendly default: a string is printed verbatim;
// a JSON array of objects renders as a kubectl-style aligned table; a
// single JSON object renders as a two-column key/value block; anything
// else falls back to indented JSON.
OutputText
// OutputTable forces table rendering. Non-tabular values render the same
// way OutputText would.
OutputTable
// OutputJSONL writes one JSON value per line. Arrays are split into
// element-per-line; non-arrays render as a single line.
OutputJSONL
// OutputYAML writes YAML.
OutputYAML
)
// ResolveOutputMode determines output format from the three flag inputs and
// stdout TTY state. Precedence: --json > --text > -o > TTY autodetect.
func ResolveOutputMode(jsonFlag, textFlag bool, outputFlag string, isTTY bool) OutputMode {
if jsonFlag {
return OutputJSON
}
if textFlag {
return OutputText
}
switch outputFlag {
case "json":
return OutputJSON
case "text":
return OutputText
case "table":
return OutputTable
case "jsonl":
return OutputJSONL
case "yaml":
return OutputYAML
case "auto", "":
// fall through to TTY autodetect
}
if isTTY {
return OutputText
}
return OutputJSON
}
// FormatOutput writes result to w in the given mode. A nil result writes nothing.
func FormatOutput(w io.Writer, result any, mode OutputMode) error {
if result == nil {
return nil
}
switch mode {
case OutputJSON:
return writeJSONCompact(w, result)
case OutputJSONL:
return writeJSONL(w, result)
case OutputYAML:
return writeYAML(w, result)
case OutputTable:
return writeTable(w, result)
case OutputText:
if s, ok := result.(string); ok {
return writeLine(w, s)
}
// Object arrays / single objects → table; otherwise indented JSON.
switch result.(type) {
case []any, []map[string]any, map[string]any:
return writeTable(w, result)
default:
return writeJSONIndent(w, result)
}
default:
return writeJSONIndent(w, result)
}
}
// FormatOutputBytes is a convenience for callers that already have a raw JSON
// response body (e.g. proxy from an HTTP endpoint). It unmarshals into a
// generic value and delegates to FormatOutput. If the body isn't valid JSON
// it's written through verbatim.
func FormatOutputBytes(w io.Writer, body []byte, mode OutputMode) error {
var v any
if err := json.Unmarshal(body, &v); err != nil {
_, werr := w.Write(body)
return werr
}
return FormatOutput(w, v, mode)
}
// AddOutputFlags registers persistent --json, --text and -o/--output flags
// on a Cobra command (typically the root). All subcommands inherit them.
func AddOutputFlags(cmd *cobra.Command) {
cmd.PersistentFlags().Bool("json", false, "Force JSON output (shortcut for -o json)")
cmd.PersistentFlags().Bool("text", false, "Force human-readable output (shortcut for -o text)")
cmd.PersistentFlags().StringP("output", "o", "auto", "Output format: auto|text|table|json|jsonl|yaml")
}
// OutputModeFromCmd resolves the output mode from --json, --text, --output
// flags and the stdout TTY state.
func OutputModeFromCmd(cmd *cobra.Command) OutputMode {
jsonFlag, _ := cmd.Flags().GetBool("json")
textFlag, _ := cmd.Flags().GetBool("text")
outputFlag, _ := cmd.Flags().GetString("output")
return ResolveOutputMode(jsonFlag, textFlag, outputFlag, isTerminal(os.Stdout))
}
// ---- formatters ---------------------------------------------------------
func writeLine(w io.Writer, s string) error {
if !strings.HasSuffix(s, "\n") {
s += "\n"
}
_, err := fmt.Fprint(w, s)
return err
}
func writeJSONCompact(w io.Writer, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(b))
return err
}
func writeJSONIndent(w io.Writer, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(b))
return err
}
func writeJSONL(w io.Writer, v any) error {
switch t := v.(type) {
case []any:
for _, item := range t {
line, err := json.Marshal(item)
if err != nil {
return err
}
if _, err := fmt.Fprintln(w, string(line)); err != nil {
return err
}
}
return nil
default:
return writeJSONCompact(w, v)
}
}
func writeYAML(w io.Writer, v any) error {
out, err := yaml.Marshal(v)
if err != nil {
return fmt.Errorf("encoding YAML: %w", err)
}
_, err = w.Write(out)
return err
}
// ---- table rendering ----------------------------------------------------
// Preferred column order for object lists. Any keys not in this list come
// after, alphabetized. Total columns capped to keep output readable.
var preferredColumns = []string{
"tag", "ref", "name", "count",
"done", "priority", "due",
"page", "lastModified",
}
const maxTableColumns = 8
const maxCellWidth = 40
// writeTable renders an array of objects as an aligned column table
// (kubectl-style), or a single object as a two-column key/value block.
// Falls back to printing primitive arrays one per line, or pretty JSON
// for shapes that don't fit either.
func writeTable(w io.Writer, v any) error {
switch t := v.(type) {
case []any:
return writeTableList(w, t)
case []map[string]any:
anys := make([]any, len(t))
for i, r := range t {
anys[i] = r
}
return writeTableList(w, anys)
case map[string]any:
return writeTableObject(w, t)
default:
return writeJSONIndent(w, v)
}
}
func writeTableList(w io.Writer, items []any) error {
if len(items) == 0 {
fmt.Fprintln(os.Stderr, "(no rows)")
return nil
}
rows := make([]map[string]any, 0, len(items))
for _, it := range items {
if m, ok := it.(map[string]any); ok {
rows = append(rows, m)
}
}
if len(rows) == 0 {
// Array of primitives — one per line.
for _, it := range items {
fmt.Fprintln(w, formatCell(it))
}
return nil
}
keySet := map[string]bool{}
for _, r := range rows {
for k := range r {
keySet[k] = true
}
}
cols := pickColumns(keySet)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, strings.Join(cols, "\t"))
for _, r := range rows {
cells := make([]string, len(cols))
for i, c := range cols {
cells[i] = formatCell(r[c])
}
fmt.Fprintln(tw, strings.Join(cells, "\t"))
}
return tw.Flush()
}
func writeTableObject(w io.Writer, obj map[string]any) error {
keys := make([]string, 0, len(obj))
for k := range obj {
keys = append(keys, k)
}
sortKeysPreferred(keys)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
for _, k := range keys {
fmt.Fprintf(tw, "%s\t%s\n", k, formatCell(obj[k]))
}
return tw.Flush()
}
func pickColumns(keys map[string]bool) []string {
var cols []string
seen := map[string]bool{}
for _, c := range preferredColumns {
if keys[c] && !seen[c] {
cols = append(cols, c)
seen[c] = true
}
if len(cols) >= maxTableColumns {
return cols
}
}
var rest []string
for k := range keys {
if !seen[k] {
rest = append(rest, k)
}
}
sort.Strings(rest)
for _, k := range rest {
if len(cols) >= maxTableColumns {
break
}
cols = append(cols, k)
}
return cols
}
func sortKeysPreferred(keys []string) {
rank := map[string]int{}
for i, k := range preferredColumns {
rank[k] = i
}
sort.SliceStable(keys, func(i, j int) bool {
ri, oki := rank[keys[i]]
rj, okj := rank[keys[j]]
if oki && okj {
return ri < rj
}
if oki {
return true
}
if okj {
return false
}
return keys[i] < keys[j]
})
}
func formatCell(v any) string {
if v == nil {
return ""
}
var s string
switch x := v.(type) {
case string:
s = x
case bool, float64, int, int64:
s = fmt.Sprintf("%v", x)
case []any, map[string]any:
b, _ := json.Marshal(x)
s = string(b)
default:
s = fmt.Sprintf("%v", x)
}
s = strings.Join(strings.Fields(s), " ")
if len([]rune(s)) > maxCellWidth {
runes := []rune(s)
s = string(runes[:maxCellWidth-1]) + "…"
}
return s
}