Files
dingtalk-workspace-cli/internal/output/formatter.go

669 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type Format string
const (
FormatJSON Format = "json"
FormatTable Format = "table"
FormatRaw Format = "raw"
FormatPretty Format = "pretty"
// FormatNDJSON emits one JSON object per line — friendly for streaming /
// piping list results into downstream tools. See ndjson.go.
FormatNDJSON Format = "ndjson"
// FormatCSV emits RFC-4180 comma-separated values for list-shaped results —
// friendly for spreadsheets and non-technical consumers. See csv.go.
FormatCSV Format = "csv"
)
// preferredListKeys is the shared allow-list of keys whose array values are
// treated as the "data list" by all tabular formatters (-f table / csv /
// ndjson). It is the single source of truth — findDataList in filter.go
// reuses it. When adding a new key, prefer real envelope keys observed in
// production responses over speculative future names.
var preferredListKeys = []string{
// Generic well-known list keys.
"value", "items", "results", "data", "list", "records",
"tools", "servers", "products",
// Envelope keys observed in real DingTalk responses.
"result", "documents", "emailAccounts", "todoCards", "events", "messages",
}
var (
marshalJSON = json.Marshal
unmarshalJSON = json.Unmarshal
marshalJSONOutput = jsonutil.Marshal
marshalJSONIndent = jsonutil.MarshalIndent
)
func ResolveFormat(cmd *cobra.Command, fallback Format) Format {
format, _ := resolveFormatWithWarning(cmd, fallback)
return format
}
// resolveFormatWithWarning 解析命令的 --format flag(B36,AC-09):已知值
// 归一化为对应 Format 常量;未知非空值**降级 fallback 并产出一条 warning
// 文本**(不崩不静默,契约规范 §5.2「未知值 → 降级 + stderr warning」)。
// warning 的写出由调用方负责(信封出口 WriteEnvelope 写 cmd.ErrOrStderr()),
// 本函数不做 I/O——ResolveFormat 的既有调用方(只读 format 值、不持有出口
// 语义)行为保持不变。flag 缺席或值为空返回 fallback 且不产生 warning
// (空值是「未指定」而非「未知」)。查找顺序与 ResolveFormat 一致:
// cmd.Flags() 优先于 InheritedFlags(),命中 format flag 即返回。
func resolveFormatWithWarning(cmd *cobra.Command, fallback Format) (Format, string) {
if cmd == nil {
return fallback, ""
}
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
value, ok := formatValueFromFlagSet(flags)
if !ok {
continue
}
format := normalizeFormat(value, fallback)
return format, unknownFormatWarning(value, format)
}
return fallback, ""
}
// formatValueFromFlagSet 返回 flagSet 上 --format flag 的原始字符串值
// (B35/B36/B43 共用的查找辅助)。flag 缺席或类型非 string 时返回 ok=false——
// 与 formatFromFlagSet 的容错口径一致(不报错、交由 fallback 兜底)。
func formatValueFromFlagSet(flags *pflag.FlagSet) (string, bool) {
if flags == nil || flags.Lookup("format") == nil {
return "", false
}
value, err := flags.GetString("format")
if err != nil {
return "", false
}
return value, true
}
// unknownFormatWarning 在 raw 为非空未知值(归一化后不等于 resolved,即不是
// 任何已知 format 的大小写/空白变体)时返回 warning 文本,否则返回空串
// (B36,AC-09 不崩不静默)。文本只陈述事实(未知值 + 降级目标),
// 不做过度承诺。
func unknownFormatWarning(raw string, resolved Format) string {
trimmed := strings.ToLower(strings.TrimSpace(raw))
if trimmed == "" || trimmed == string(resolved) {
return ""
}
return fmt.Sprintf("unknown --format %q, falling back to %q", raw, string(resolved))
}
// ResolveFormatWithJSONShorthand 解析命令的输出 format,实现 --json 简写语义
// (B43,契约规范 §5.2:「--json 自动注册为 --format json 简写(显式
// --format 优先)」)。优先级链:
//
// 1. 显式 --format(非空值)恒优先——含未知值(按 normalizeFormat 降级
// fallback,--json 不 rescues 未知值);
// 2. --format 缺席或为空时,--json 布尔 flag(Changed 且为 true)等价
// --format json;
// 3. 两者皆无 → fallback。
//
// --json 判定只接受 bool 型 flag(GetBool 成功):业务层存在同名 string
// flag(如 table create --json 载荷参数)的命令不会被误判为简写。本函数
// 纯判定不做 I/O,供不直接走 WriteEnvelope 的调用方(如主漏斗桥接,B44)
// 复用;与 resolveFormatWithWarning 共用 formatValueFromFlagSet /
// normalizeFormat,归一化规则单一事实源。
func ResolveFormatWithJSONShorthand(cmd *cobra.Command, fallback Format) Format {
if cmd == nil {
return fallback
}
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
value, ok := formatValueFromFlagSet(flags)
if !ok {
continue
}
if strings.TrimSpace(value) != "" {
return normalizeFormat(value, fallback)
}
}
if jsonShorthandActive(cmd) {
return FormatJSON
}
return fallback
}
// jsonShorthandActive 报告 cmd 上是否有生效的 --json 简写(bool flag 被显式
// 设置为 true)。查找 Flags / InheritedFlags / PersistentFlags 三组,与
// internal/app 侧 commandRequestsJSONErrors 的判定面一致;GetBool 失败
// (非 bool 同名 flag)跳过该组继续。
func jsonShorthandActive(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags(), cmd.PersistentFlags()} {
flag := flags.Lookup("json")
if flag == nil || !flag.Changed {
continue
}
if value, err := flags.GetBool("json"); err == nil {
return value
}
}
return false
}
func WriteCommandPayload(cmd *cobra.Command, payload any, fallback Format) error {
if cmd == nil {
return Write(io.Discard, fallback, payload)
}
return WriteFiltered(
cmd.OutOrStdout(),
ResolveFormat(cmd, fallback),
payload,
ResolveFields(cmd),
ResolveJQ(cmd),
)
}
func Write(w io.Writer, format Format, payload any) error {
payload = unwrapCompatRuntimePayload(payload)
switch format {
case FormatJSON:
return WriteJSON(w, payload)
case FormatRaw:
return writeRaw(w, payload)
case FormatTable:
return writeTableish(w, payload)
case FormatPretty:
return writePretty(w, payload)
case FormatNDJSON:
return writeNDJSON(w, payload)
case FormatCSV:
return writeCSV(w, payload)
default:
return WriteJSON(w, payload)
}
}
func unwrapCompatRuntimePayload(payload any) any {
result, ok := payload.(executor.Result)
if !ok {
return payload
}
if !result.Invocation.Implemented {
return payload
}
switch result.Invocation.Kind {
case "compat_invocation", "helper_invocation":
content, ok := result.Response["content"]
if ok {
return content
}
}
return payload
}
func formatFromFlagSet(flags *pflag.FlagSet, fallback Format) (Format, bool) {
value, ok := formatValueFromFlagSet(flags)
if !ok {
return fallback, false
}
return normalizeFormat(value, fallback), true
}
func normalizeFormat(raw string, fallback Format) Format {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", string(fallback):
return fallback
case string(FormatJSON):
return FormatJSON
case string(FormatRaw):
return FormatRaw
case string(FormatTable):
return FormatTable
case string(FormatPretty):
return FormatPretty
case string(FormatNDJSON):
return FormatNDJSON
case string(FormatCSV):
return FormatCSV
default:
return fallback
}
}
// WriteFiltered applies field selection and/or jq filtering before
// writing the payload. If jq is non-empty, the jq result is written
// directly (bypassing format). If fields is non-empty, the payload
// is filtered to those fields before normal output.
func WriteFiltered(w io.Writer, format Format, payload any, fields, jq string) error {
payload = unwrapCompatRuntimePayload(payload)
if strings.TrimSpace(jq) != "" {
return ApplyJQ(w, payload, strings.TrimSpace(jq))
}
if strings.TrimSpace(fields) != "" {
fieldList := strings.Split(fields, ",")
payload = SelectFields(payload, fieldList)
}
return Write(w, format, payload)
}
// ResolveFields extracts the --fields flag value from the command.
// It ensures that we do not mistakenly grab a business parameter also named "fields"
// by matching the flag's usage string against the global root definition.
func ResolveFields(cmd *cobra.Command) string {
if cmd == nil {
return ""
}
rootFlags := rootPersistentFlags(cmd)
globalFlag := rootFlags.Lookup("fields")
if globalFlag == nil {
return ""
}
for _, flags := range []*pflag.FlagSet{
cmd.Flags(),
cmd.InheritedFlags(),
rootFlags,
} {
if f := flags.Lookup("fields"); f != nil && f.Changed {
// To avoid collision with business flags (e.g. table create --fields),
// verify this flag shares the same usage string as the global one.
if f.Usage == globalFlag.Usage {
if v, err := flags.GetString("fields"); err == nil {
return v
}
}
}
}
return ""
}
// ResolveJQ extracts the --jq flag value from the command. It ensures
// that we only grab the global output filter, not a similarly named business parameter.
func ResolveJQ(cmd *cobra.Command) string {
if cmd == nil {
return ""
}
rootFlags := rootPersistentFlags(cmd)
globalFlag := rootFlags.Lookup("jq")
if globalFlag == nil {
return ""
}
for _, flags := range []*pflag.FlagSet{
cmd.Flags(),
cmd.InheritedFlags(),
rootFlags,
} {
if f := flags.Lookup("jq"); f != nil && f.Changed {
if f.Usage == globalFlag.Usage {
if v, err := flags.GetString("jq"); err == nil {
return v
}
}
}
}
return ""
}
func rootPersistentFlags(cmd *cobra.Command) *pflag.FlagSet {
if cmd == nil {
return nil
}
return cmd.Root().PersistentFlags()
}
// WriteJSON marshals payload as indented JSON and writes it to w.
func WriteJSON(w io.Writer, payload any) error {
data, err := marshalJSONIndent(payload, "", " ")
if err != nil {
return apperrors.NewInternal("failed to encode command output as JSON")
}
_, err = fmt.Fprintln(w, string(data))
return err
}
func writeRaw(w io.Writer, payload any) error {
if text, ok := payload.(string); ok {
_, err := fmt.Fprintln(w, SanitizeForTerminal(text))
return err
}
data, err := marshalJSONOutput(payload)
if err != nil {
return apperrors.NewInternal("failed to encode raw command output")
}
_, err = fmt.Fprintln(w, SanitizeForTerminal(string(data)))
return err
}
func writeTableish(w io.Writer, payload any) error {
normalized, err := normalizePayload(payload)
if err != nil {
return err
}
switch typed := normalized.(type) {
case map[string]any:
// Try table extraction first so wrappers around list payloads
// (e.g. {result: {todoCards: [...]}}) render as a table instead
// of being peeled by unwrapPrimaryObject and degraded to key/
// value rows. unwrapPrimaryObject remains the fallback for
// single-object wrappers like {invocation: {kind, params, ...}}.
if headers, rows, meta, ok := extractRowsFromMap(typed); ok {
if err := writeTable(w, headers, rows); err != nil {
return err
}
if len(meta) > 0 {
if _, err := fmt.Fprintln(w); err != nil {
return err
}
return writeKeyValues(w, meta)
}
return nil
}
if inner, ok := unwrapPrimaryObject(typed); ok {
return writeKeyValues(w, inner)
}
return writeKeyValues(w, typed)
case []any:
headers, rows := rowsFromSlice(typed)
return writeTable(w, headers, rows)
default:
return writeRaw(w, normalized)
}
}
func normalizePayload(payload any) (any, error) {
if payload == nil {
return nil, nil
}
if text, ok := payload.(string); ok {
return text, nil
}
data, err := marshalJSON(payload)
if err != nil {
return nil, apperrors.NewInternal("failed to normalize command output")
}
var normalized any
if err := unmarshalJSON(data, &normalized); err != nil {
return nil, apperrors.NewInternal("failed to decode normalized command output")
}
return normalized, nil
}
func unwrapPrimaryObject(payload map[string]any) (map[string]any, bool) {
if len(payload) != 1 {
return nil, false
}
for _, key := range []string{"invocation", "response", "result", "data"} {
value, ok := payload[key]
if !ok {
continue
}
object, ok := value.(map[string]any)
if ok {
return object, true
}
}
return nil, false
}
// extractRowsFromMap finds the data list inside a wrapper map and returns it
// as (headers, rows, meta). It delegates the search to findDataList so the
// detection rules stay aligned with -f ndjson: top-level under a preferred
// key, or one level deep under {result|response|data}. Meta is built from
// every sibling of the list — at both the outer and inner level when the
// list sits one level deep — so callers like the table renderer's footer and
// the csv broadcastMeta path see the same key set.
func extractRowsFromMap(payload map[string]any) ([]string, [][]string, map[string]any, bool) {
loc := findDataList(payload)
if loc == nil {
return nil, nil, nil, false
}
headers, rows := rowsFromSlice(loc.list)
meta := make(map[string]any)
if loc.outerKey == "" {
for k, v := range payload {
if k == loc.innerKey {
continue
}
meta[k] = v
}
} else {
for k, v := range payload {
if k == loc.outerKey {
continue
}
meta[k] = v
}
inner := payload[loc.outerKey].(map[string]any)
for k, v := range inner {
if k == loc.innerKey {
continue
}
if _, exists := meta[k]; exists {
// Outer wins on key collision so users see the wrapper-level
// sibling rather than a clobbered inner one.
continue
}
meta[k] = v
}
}
return headers, rows, meta, true
}
func rowsFromSlice(items []any) ([]string, [][]string) {
if len(items) == 0 {
return []string{"value"}, [][]string{}
}
allMaps := true
keys := make(map[string]struct{})
for _, item := range items {
rowMap, ok := item.(map[string]any)
if !ok {
allMaps = false
break
}
for key := range rowMap {
keys[key] = struct{}{}
}
}
if allMaps {
headers := sortedKeys(keys)
rows := make([][]string, 0, len(items))
for _, item := range items {
rowMap := item.(map[string]any)
row := make([]string, 0, len(headers))
for _, key := range headers {
row = append(row, formatValue(rowMap[key]))
}
rows = append(rows, row)
}
return headers, rows
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{formatValue(item)})
}
return []string{"value"}, rows
}
func writeKeyValues(w io.Writer, payload map[string]any) error {
keys := make([]string, 0, len(payload))
maxWidth := 0
for key := range payload {
keys = append(keys, key)
if width := runeWidth(key); width > maxWidth {
maxWidth = width
}
}
sort.Strings(keys)
if maxWidth > 24 {
maxWidth = 24
}
for _, key := range keys {
label := tui.PadRightANSI(tui.Key(key), maxWidth+1)
if _, err := fmt.Fprintf(w, "%s %s\n", label, formatValue(payload[key])); err != nil {
return err
}
}
return nil
}
func writeTable(w io.Writer, headers []string, rows [][]string) error {
widths := make([]int, len(headers))
for i, header := range headers {
widths[i] = runeWidth(header)
}
for _, row := range rows {
for i, cell := range row {
if i >= len(widths) {
continue
}
if width := runeWidth(cell); width > widths[i] {
widths[i] = width
}
}
}
for i := range widths {
if widths[i] > tui.MaxTableColumnWidth {
widths[i] = tui.MaxTableColumnWidth
}
}
if len(widths) == 0 {
return nil
}
writeRow := func(values []string, render func(string) string) error {
if _, err := io.WriteString(w, tui.Gray("│ ")); err != nil {
return err
}
for i := range widths {
if i > 0 {
if _, err := io.WriteString(w, tui.Gray(" │ ")); err != nil {
return err
}
}
cell := ""
if i < len(values) {
cell = values[i]
}
cell = truncate(cell, widths[i])
if _, err := io.WriteString(w, tui.PadRightANSI(render(cell), widths[i])); err != nil {
return err
}
}
_, err := io.WriteString(w, tui.Gray(" │\n"))
return err
}
writeDivider := func(left, mid, right string, render func(string) string) error {
if _, err := io.WriteString(w, render(left)); err != nil {
return err
}
for i, width := range widths {
if i > 0 {
if _, err := io.WriteString(w, render(mid)); err != nil {
return err
}
}
if _, err := io.WriteString(w, render(strings.Repeat("─", width+2))); err != nil {
return err
}
}
_, err := io.WriteString(w, render(right+"\n"))
return err
}
if err := writeDivider("╭", "┬", "╮", tui.Blue); err != nil {
return err
}
if err := writeRow(headers, tui.Brand); err != nil {
return err
}
if err := writeDivider("├", "┼", "┤", tui.Gray); err != nil {
return err
}
for _, row := range rows {
if err := writeRow(row, tui.White); err != nil {
return err
}
}
return writeDivider("╰", "┴", "╯", tui.Blue)
}
func sortedKeys(keys map[string]struct{}) []string {
out := make([]string, 0, len(keys))
for key := range keys {
out = append(out, key)
}
sort.Strings(out)
return out
}
func formatValue(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return SanitizeForTerminal(typed)
default:
data, err := marshalJSONOutput(typed)
if err != nil {
return SanitizeForTerminal(fmt.Sprintf("%v", typed))
}
return SanitizeForTerminal(string(data))
}
}
func truncate(s string, maxWidth int) string {
if maxWidth <= 0 || runeWidth(s) <= maxWidth {
return s
}
if maxWidth == 1 {
return "…"
}
var b strings.Builder
used := 0
limit := maxWidth - 1
for _, r := range s {
width := runeWidth(string(r))
if used+width > limit {
break
}
b.WriteRune(r)
used += width
}
return b.String() + "…"
}
func runeWidth(s string) int {
return tui.PlainRuneWidth(s)
}