341 lines
8.1 KiB
Go
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
|
|
}
|