mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
* refactor: add output emitter contract and differential harness Introduce a leaf Emitter in internal/output that composes the existing output primitives (content-safety scan, envelope, jq, format rendering, notice) behind a single command-scoped port. The emitter is unwired: no production caller is migrated, so CLI output stays byte-for-byte unchanged. A differential test harness drives the real legacy entry points (RuntimeContext.Out/OutRaw/OutFormat/..., WriteSuccessEnvelope and the pagination formatter) and asserts byte-identical stdout/stderr plus typed errors, locking behavior before later slices migrate callers. * refactor: tighten emitter API and cover pagination with real tests - split Emitter.Success/PartialFailure and drop EmitOptions.OK so a missing ok flag can no longer silently emit ok:false - give StreamPage its own StreamOptions (format + pretty) instead of reusing EmitOptions, making "jq needs aggregation" a compile-time fact - pin the Emitter jq-error contract (returns error, writes no stderr); the caller adapter re-emits the legacy stderr line on migration - add in-package tests driving the real apiPaginate/servicePaginate over a mock transport: multi-page aggregation, empty-result fallback, MarkRaw handling, and the business-error raw-response red line * test: use standard TestFactory harness for pagination tests Replace the hand-rolled RoundTripper + APIClient construction in the apiPaginate/servicePaginate tests with cmdutil.TestFactory and its httpmock.Registry, and isolate LARKSUITE_CLI_CONFIG_DIR to t.TempDir(), matching the repo's standard HTTP-mocked test convention. Assertions and coverage (multi-page aggregation, empty-result fallback, MarkRaw, and the business-error raw-response red line) are unchanged. * refactor: route success output through the single Emitter port Migrate the success-output surfaces onto internal/output's Emitter, byte-for-byte identical (proven by frozen golden diffs and the real paginate/HandleResponse tests): - RuntimeContext.Out/OutRaw/OutFormat/OutFormatRaw/OutPartialFailure now build an Emitter and call Success/PartialFailure; emit and outFormat are removed. An adapter maps the returned error back to the legacy outputErrOnce / jq-error stderr / exit-code behavior. - WriteSuccessEnvelope degrades to a thin Emitter.Success delegate; its 8 callers are unchanged. - apiPaginate/servicePaginate stream pages via Emitter.StreamPage; the aggregate and business-error raw-response branches are untouched. - HandleResponse routes its non-JSON structured-response branch through Emitter.Success. Frozen golden fixtures replace the runtime legacy oracles so the differential harness cannot go self-referential after migration. * fix: keep _notice on struct payloads in Emitter's unknown-format fallback printLegacyDataJSON now normalizes via toGeneric first (matching FormatValue), so a struct / named-map payload retains its injected _notice on the unknown-format -> JSON fallback rather than dropping it silently. Add a regression test that fails against the pre-fix path. * refactor: make the Emitter own write failures and stop mutating inputs Route every Emitter stdout path through a render-to-buffer-then-copy helper so a marshal/render failure leaves stdout empty and surfaces a typed internal error (with cause), and a stdout write failure is propagated instead of silently swallowed. Leaf writers gain error-returning Write* cores; the legacy Print*/FormatValue wrappers keep their exact behavior for unmigrated callers. - handleEmitterError now captures every error, not only the jq/safety branches; flip OutRaw's write-error test to assert propagation. - Clone the map before injecting _notice so a caller's payload is never mutated and an existing _notice is never overwritten. - Preserve jq's own typed error (validation/api) on a bad expression or runtime failure; only wrap genuine stdout write failures. - Split tests: normative emitter_contract_test.go vs frozen emitter_legacy_compat_test.go (base SHA recorded, self-update env vars removed). * fix: satisfy license-header and forbidigo lint on the emitter changes - Move the base-SHA note below the copyright header in the renamed legacy-compat test so the license-header check sees a valid header at the top. - Route the leaf wrappers' marshal/format stderr messages through a single legacyStderrf helper (one //nolint:forbidigo) instead of bare os.Stderr, preserving exact legacy behavior for unmigrated direct callers while passing forbidigo; drop the now-unused os imports. * fix: stop legacy CSV wrappers reporting write failures to stderr Align FormatAsCSV/FormatAsCSVPaginated and FormatValue/FormatPage's CSV branch with the other leaf wrappers: report only marshal failures, swallow write failures. Previously they emitted a 'csv write error' for the (empty) line and the JSON-fallback write failures that the pre-refactor code ignored, and mislabeled a JSON write failure as a CSV one. Failure-path only; success output is unchanged (golden double-diff still byte-for-byte).
221 lines
6.1 KiB
Go
221 lines
6.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package output
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
)
|
|
|
|
// Known array field names for pagination.
|
|
var knownArrayFields = []string{
|
|
"items", "files", "events", "rooms", "records", "nodes",
|
|
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
|
|
"users",
|
|
}
|
|
|
|
// FindArrayField finds the primary array field in a response's data object.
|
|
// It first checks knownArrayFields in priority order, then falls back to
|
|
// the lexicographically smallest unknown array field for deterministic results.
|
|
func FindArrayField(data map[string]interface{}) string {
|
|
for _, name := range knownArrayFields {
|
|
if arr, ok := data[name]; ok {
|
|
if _, isArr := arr.([]interface{}); isArr {
|
|
return name
|
|
}
|
|
}
|
|
}
|
|
// Fallback: lexicographically first array field (deterministic)
|
|
var candidates []string
|
|
for k, v := range data {
|
|
if _, isArr := v.([]interface{}); isArr {
|
|
candidates = append(candidates, k)
|
|
}
|
|
}
|
|
if len(candidates) > 0 {
|
|
sort.Strings(candidates)
|
|
return candidates[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// toGeneric normalises any Go value (structs, typed slices, …) into
|
|
// plain map[string]interface{} / []interface{} via a JSON round-trip so
|
|
// that subsequent type assertions in format handlers work uniformly.
|
|
func toGeneric(v interface{}) interface{} {
|
|
switch v.(type) {
|
|
case map[string]interface{}, []interface{}, nil:
|
|
return v // already generic
|
|
}
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return v
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(b))
|
|
dec.UseNumber() // preserve int64 precision (avoid float64 truncation)
|
|
var out interface{}
|
|
if err := dec.Decode(&out); err != nil {
|
|
return v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ExtractItems extracts the data array from a response.
|
|
// It tries two strategies in order:
|
|
// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}})
|
|
// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5})
|
|
//
|
|
// If data is already a plain []interface{}, it is returned as-is.
|
|
func ExtractItems(data interface{}) []interface{} {
|
|
resultMap, ok := data.(map[string]interface{})
|
|
if !ok {
|
|
if arr, ok := data.([]interface{}); ok {
|
|
return arr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Strategy 1: Lark API envelope — result["data"][arrayField]
|
|
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
|
|
if field := FindArrayField(dataObj); field != "" {
|
|
if items, ok := dataObj[field].([]interface{}); ok {
|
|
return items
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 2: direct map — result[arrayField]
|
|
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
|
|
if field := FindArrayField(resultMap); field != "" {
|
|
if items, ok := resultMap[field].([]interface{}); ok {
|
|
return items
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FormatValue formats a single response and writes it to w.
|
|
func FormatValue(w io.Writer, data interface{}, format Format) {
|
|
err := WriteFormatted(w, data, format)
|
|
switch {
|
|
case err == nil:
|
|
return
|
|
case isOutputMarshalError(err) && format == FormatNDJSON:
|
|
legacyStderrf("ndjson marshal error: %v\n", err)
|
|
case isOutputMarshalError(err):
|
|
legacyStderrf("json marshal error: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// WriteFormatted formats a single response and returns marshal or write errors.
|
|
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
|
|
data = toGeneric(data)
|
|
switch format {
|
|
case FormatNDJSON:
|
|
items := ExtractItems(data)
|
|
if items != nil {
|
|
return WriteNDJSON(w, items)
|
|
}
|
|
return WriteNDJSON(w, data)
|
|
|
|
case FormatTable:
|
|
items := ExtractItems(data)
|
|
if items != nil {
|
|
return WriteTable(w, items)
|
|
}
|
|
return WriteTable(w, data)
|
|
|
|
case FormatCSV:
|
|
items := ExtractItems(data)
|
|
if items != nil {
|
|
return WriteCSV(w, items)
|
|
}
|
|
return WriteCSV(w, data)
|
|
|
|
default: // FormatJSON
|
|
return WriteJSON(w, data)
|
|
}
|
|
}
|
|
|
|
// PaginatedFormatter holds state across paginated calls to ensure
|
|
// consistent columns (table/csv use the first page's columns for all pages).
|
|
type PaginatedFormatter struct {
|
|
W io.Writer
|
|
Format Format
|
|
isFirstPage bool
|
|
cols []string // locked after first page
|
|
}
|
|
|
|
// NewPaginatedFormatter creates a formatter that tracks pagination state.
|
|
func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
|
|
return &PaginatedFormatter{W: w, Format: format, isFirstPage: true}
|
|
}
|
|
|
|
// FormatPage formats one page of items.
|
|
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
|
|
err := pf.WritePage(data)
|
|
if isOutputMarshalError(err) && (pf.Format == FormatJSON || pf.Format == FormatNDJSON) {
|
|
legacyStderrf("ndjson marshal error: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// WritePage formats one page of items and returns marshal or write errors.
|
|
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
|
|
switch pf.Format {
|
|
case FormatJSON, FormatNDJSON:
|
|
if arr, ok := data.([]interface{}); ok {
|
|
return WriteNDJSON(pf.W, arr)
|
|
}
|
|
return WriteNDJSON(pf.W, data)
|
|
|
|
case FormatTable:
|
|
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
|
|
widths := computeColumnWidths(rows, cols)
|
|
if isFirst {
|
|
if err := writeHeader(w, cols, widths); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, row := range rows {
|
|
if err := writeRow(w, row, cols, widths); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
case FormatCSV:
|
|
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
|
|
return writeCSVRows(w, rows, cols, isFirst)
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// formatStructuredPage handles column-locking logic shared by table and csv.
|
|
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool) error) error {
|
|
rows, pageCols, isList := prepareRows(data)
|
|
if len(rows) == 0 {
|
|
if pf.isFirstPage && isList {
|
|
_, err := fmt.Fprintln(pf.W, "(empty)")
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if pf.isFirstPage {
|
|
// Lock columns from first page
|
|
pf.cols = pageCols
|
|
pf.isFirstPage = false
|
|
return emit(pf.W, rows, pf.cols, true)
|
|
} else {
|
|
// Reuse first page's columns — missing keys become empty, extra keys ignored
|
|
return emit(pf.W, rows, pf.cols, false)
|
|
}
|
|
}
|