forked from Gitlink/gitlink-cli
301 lines
6.8 KiB
Go
301 lines
6.8 KiB
Go
package output
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"golang.org/x/term"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// PrintOptions controls table output rendering behavior.
|
|
type PrintOptions struct {
|
|
Columns []string // column names to show (nil = all)
|
|
NoTruncate bool // disable 60-char truncation
|
|
UseColor bool // enable ANSI color headers
|
|
}
|
|
|
|
// Print outputs the envelope in the given format with default options.
|
|
func Print(envelope *Envelope, format string) error {
|
|
return PrintWithOpts(envelope, format, PrintOptions{})
|
|
}
|
|
|
|
// PrintWithOpts outputs the envelope with rendering options.
|
|
func PrintWithOpts(envelope *Envelope, format string, opts PrintOptions) error {
|
|
if format == "" {
|
|
format = "json"
|
|
}
|
|
return printToOpts(os.Stdout, envelope, format, opts)
|
|
}
|
|
|
|
func printToOpts(w io.Writer, envelope *Envelope, format string, opts PrintOptions) error {
|
|
switch format {
|
|
case "json":
|
|
return printJSON(w, envelope)
|
|
case "yaml":
|
|
return printYAML(w, envelope)
|
|
case "table":
|
|
return printTableOpts(w, envelope, opts)
|
|
default:
|
|
return printJSON(w, envelope)
|
|
}
|
|
}
|
|
|
|
// PrintTo outputs the envelope to the given writer (legacy, no options).
|
|
func PrintTo(w io.Writer, envelope *Envelope, format string) error {
|
|
return printToOpts(w, envelope, format, PrintOptions{})
|
|
}
|
|
|
|
func printJSON(w io.Writer, envelope *Envelope) error {
|
|
data, err := json.MarshalIndent(envelope, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintln(w, string(data))
|
|
return err
|
|
}
|
|
|
|
func printYAML(w io.Writer, envelope *Envelope) error {
|
|
data, err := yaml.Marshal(envelope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprint(w, string(data))
|
|
return err
|
|
}
|
|
|
|
func printTableOpts(w io.Writer, envelope *Envelope, opts PrintOptions) error {
|
|
if !envelope.OK {
|
|
if envelope.Error != nil {
|
|
fmt.Fprintf(w, "Error: %s\n", envelope.Error.Message)
|
|
if envelope.Error.Suggestion != "" {
|
|
fmt.Fprintf(w, "Suggestion: %s\n", envelope.Error.Suggestion)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if envelope.Data == nil {
|
|
fmt.Fprintln(w, "No data")
|
|
return nil
|
|
}
|
|
|
|
switch data := envelope.Data.(type) {
|
|
case []interface{}:
|
|
return printSliceTableOpts(w, data, opts)
|
|
case map[string]interface{}:
|
|
if unwrapped := unwrapSingleListField(data); unwrapped != nil {
|
|
return printSliceTableOpts(w, unwrapped, opts)
|
|
}
|
|
if hasComplexValues(data) {
|
|
return printJSON(w, envelope)
|
|
}
|
|
return printMapTableOpts(w, data, opts)
|
|
default:
|
|
return printJSON(w, envelope)
|
|
}
|
|
}
|
|
|
|
// printTable is kept for backward compatibility with existing callers.
|
|
func printTable(w io.Writer, envelope *Envelope) error {
|
|
return printTableOpts(w, envelope, PrintOptions{})
|
|
}
|
|
|
|
// unwrapSingleListField detects wrapper map structures like {"items":[...], "count":N}
|
|
// and returns the inner slice for list rendering.
|
|
func unwrapSingleListField(m map[string]interface{}) []interface{} {
|
|
knownListFields := []string{
|
|
"projects", "webhooks", "issues", "users", "pull_requests",
|
|
"builds", "releases", "branches", "teams", "members",
|
|
"orgs", "items", "records", "results", "wikis", "search",
|
|
}
|
|
|
|
for _, name := range knownListFields {
|
|
if s, ok := m[name].([]interface{}); ok {
|
|
if isSliceOfMaps(s) {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
|
|
// fallback: single slice-of-maps field
|
|
var listField string
|
|
var listValue []interface{}
|
|
for k, v := range m {
|
|
s, ok := v.([]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !isSliceOfMaps(s) {
|
|
continue
|
|
}
|
|
if listField != "" {
|
|
return nil // multiple list fields, can't auto-unwrap
|
|
}
|
|
listField = k
|
|
listValue = s
|
|
}
|
|
return listValue
|
|
}
|
|
|
|
func isSliceOfMaps(s []interface{}) bool {
|
|
if len(s) == 0 {
|
|
return true
|
|
}
|
|
_, ok := s[0].(map[string]interface{})
|
|
return ok
|
|
}
|
|
|
|
func hasComplexValues(m map[string]interface{}) bool {
|
|
for _, v := range m {
|
|
switch v.(type) {
|
|
case map[string]interface{}, []interface{}:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func printSliceTableOpts(w io.Writer, items []interface{}, opts PrintOptions) error {
|
|
if len(items) == 0 {
|
|
fmt.Fprintln(w, "No results")
|
|
return nil
|
|
}
|
|
|
|
first, ok := items[0].(map[string]interface{})
|
|
if !ok {
|
|
data, _ := json.MarshalIndent(items, "", " ")
|
|
fmt.Fprintln(w, string(data))
|
|
return nil
|
|
}
|
|
|
|
headers := collectKeys(first)
|
|
|
|
// apply --columns filter
|
|
if len(opts.Columns) > 0 {
|
|
headers = filterColumns(headers, opts.Columns)
|
|
}
|
|
|
|
useColor := opts.UseColor && isTerminal(w)
|
|
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
|
|
// Print headers
|
|
headerLine := strings.Join(headers, "\t")
|
|
if useColor {
|
|
headerLine = colorHeader(headerLine)
|
|
}
|
|
fmt.Fprintln(tw, headerLine)
|
|
|
|
dashes := make([]string, len(headers))
|
|
for i, h := range headers {
|
|
dashes[i] = strings.Repeat("-", len(h))
|
|
}
|
|
fmt.Fprintln(tw, strings.Join(dashes, "\t"))
|
|
|
|
// Print rows
|
|
for _, item := range items {
|
|
m, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
vals := make([]string, len(headers))
|
|
for i, h := range headers {
|
|
vals[i] = formatValueOpts(m[h], opts.NoTruncate)
|
|
}
|
|
fmt.Fprintln(tw, strings.Join(vals, "\t"))
|
|
}
|
|
return tw.Flush()
|
|
}
|
|
|
|
func printMapTableOpts(w io.Writer, m map[string]interface{}, opts PrintOptions) error {
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
headerLine := "KEY\tVALUE"
|
|
if opts.UseColor && isTerminal(w) {
|
|
headerLine = colorHeader(headerLine)
|
|
}
|
|
fmt.Fprintln(tw, headerLine)
|
|
fmt.Fprintln(tw, "---\t-----")
|
|
for k, v := range m {
|
|
fmt.Fprintf(tw, "%s\t%s\n", k, formatValueOpts(v, opts.NoTruncate))
|
|
}
|
|
return tw.Flush()
|
|
}
|
|
|
|
func filterColumns(all, wanted []string) []string {
|
|
wantedSet := make(map[string]bool, len(wanted))
|
|
for _, w := range wanted {
|
|
wantedSet[w] = true
|
|
}
|
|
result := make([]string, 0, len(wanted))
|
|
for _, h := range all {
|
|
if wantedSet[h] {
|
|
result = append(result, h)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func collectKeys(m map[string]interface{}) []string {
|
|
keys := make([]string, 0, len(m))
|
|
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
|
|
seen := map[string]bool{}
|
|
for _, k := range priority {
|
|
if _, ok := m[k]; ok {
|
|
keys = append(keys, k)
|
|
seen[k] = true
|
|
}
|
|
}
|
|
for k := range m {
|
|
if !seen[k] {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func formatValue(v interface{}) string {
|
|
return formatValueOpts(v, false)
|
|
}
|
|
|
|
func formatValueOpts(v interface{}, noTruncate bool) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
rv := reflect.ValueOf(v)
|
|
switch rv.Kind() {
|
|
case reflect.Map, reflect.Slice:
|
|
data, _ := json.Marshal(v)
|
|
s := string(data)
|
|
if !noTruncate && len(s) > 60 {
|
|
return s[:57] + "..."
|
|
}
|
|
return s
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
// --- color helpers ---
|
|
|
|
const (
|
|
ansiHeader = "\033[1;36m" // bold cyan
|
|
ansiReset = "\033[0m"
|
|
)
|
|
|
|
func colorHeader(s string) string {
|
|
return ansiHeader + s + ansiReset
|
|
}
|
|
|
|
func isTerminal(w io.Writer) bool {
|
|
if f, ok := w.(*os.File); ok {
|
|
return term.IsTerminal(int(f.Fd()))
|
|
}
|
|
return false
|
|
}
|