diff --git a/doc/changes/table-list-rendering.md b/doc/changes/table-list-rendering.md new file mode 100644 index 0000000..8cc287f --- /dev/null +++ b/doc/changes/table-list-rendering.md @@ -0,0 +1,25 @@ +# `--format table` 支持资源包裹列表响应 + +## 动机 + +平台所有分页列表端点的响应形状都是资源包裹 map: +`{"total_count": N, "<资源名>": [...]}`。此前 `printTable` 遇到含嵌套 +结构的 map 一律回落 JSON,导致 **所有 list 命令的 `--format table` +实际上从不渲染表格**,与 flag 文案承诺不符。 + +## 行为 + +- 检测「恰好一个数组值 + 其余全是标量」的 map(列表响应形状): + 先打印标量摘要行(如 `total_count: 7`,键排序),再把包裹数组 + 渲染为表格。 +- 含嵌套对象的 map(如 commit 详情)与既有行为一致回落 JSON。 + +## 生产实测 + +`branch +list --format table`(forgeplus):输出 `total_count: 7` +摘要行 + 7 行分支表格(此前输出整段 JSON)。 + +## 测试 + +formatter_test.go 新增 2 个单测:资源包裹列表渲染表格(含摘要行、 +不回落 JSON);嵌套对象 map 仍回落 JSON。 diff --git a/internal/output/formatter.go b/internal/output/formatter.go index dd0b59c..95ffa2b 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -6,6 +6,7 @@ import ( "io" "os" "reflect" + "sort" "strings" "text/tabwriter" @@ -71,6 +72,11 @@ func printTable(w io.Writer, envelope *Envelope) error { case []interface{}: return printSliceTable(w, data) case map[string]interface{}: + // Resource-wrapped list responses ({"total_count": N, "": [...]}) + // render the wrapped array as a table with the scalar fields as a summary. + if items, ok := unwrapListMap(w, data); ok { + return printSliceTable(w, items) + } // For maps with nested structures, prefer JSON if hasComplexValues(data) { return printJSON(w, envelope) @@ -82,6 +88,47 @@ func printTable(w io.Writer, envelope *Envelope) error { } } +// unwrapListMap detects a map containing exactly one array value while every +// other value is a scalar (the shape of the platform's paginated list +// responses). It prints the scalar fields as a summary line and returns the +// wrapped array for table rendering. +func unwrapListMap(w io.Writer, m map[string]interface{}) ([]interface{}, bool) { + var items []interface{} + arrays := 0 + for _, v := range m { + switch value := v.(type) { + case []interface{}: + arrays++ + items = value + case map[string]interface{}: + return nil, false + } + } + if arrays != 1 { + return nil, false + } + scalars := make([]string, 0, len(m)) + for _, k := range sortedKeys(m) { + if _, ok := m[k].([]interface{}); ok { + continue + } + scalars = append(scalars, fmt.Sprintf("%s: %s", k, formatValue(m[k]))) + } + if len(scalars) > 0 { + fmt.Fprintln(w, strings.Join(scalars, " ")) + } + return items, true +} + +func sortedKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + func hasComplexValues(m map[string]interface{}) bool { for _, v := range m { switch v.(type) { diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index 80430b9..9ccd313 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -244,3 +244,41 @@ func TestFormatValue(t *testing.T) { }) } } + +func TestPrintTableUnwrapsResourceWrappedList(t *testing.T) { + env := SuccessEnvelope(map[string]interface{}{ + "total_count": 2, + "tags": []interface{}{ + map[string]interface{}{"name": "v1.0.0", "id": 1}, + map[string]interface{}{"name": "v1.1.0", "id": 2}, + }, + }, nil) + var buf bytes.Buffer + if err := PrintTo(&buf, env, "table"); err != nil { + t.Fatalf("PrintTo returned error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "total_count: 2") { + t.Fatalf("missing summary line: %s", out) + } + if !strings.Contains(out, "v1.0.0") || !strings.Contains(out, "v1.1.0") { + t.Fatalf("missing table rows: %s", out) + } + if strings.Contains(out, "\"ok\"") { + t.Fatalf("should not fall back to JSON: %s", out) + } +} + +func TestPrintTableKeepsJSONForNestedObjects(t *testing.T) { + env := SuccessEnvelope(map[string]interface{}{ + "commit": map[string]interface{}{"sha": "abc"}, + "files": []interface{}{}, + }, nil) + var buf bytes.Buffer + if err := PrintTo(&buf, env, "table"); err != nil { + t.Fatalf("PrintTo returned error: %v", err) + } + if !strings.Contains(buf.String(), "\"ok\"") { + t.Fatalf("nested object map should fall back to JSON: %s", buf.String()) + } +}