chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import "fmt"
// BareError is the silent-exit signal for commands whose stdout already
// carries the complete answer and that only need the matching exit code
// without a stderr envelope. Two cases use it: a predicate writing its yes/no
// JSON (e.g. `auth check` exiting non-zero on a no-token state), and a command
// emitting its own structured result envelope under `--json` (e.g. `update`).
// Deliberately outside the typed-envelope contract.
type BareError struct{ Code int }
func (e *BareError) Error() string { return fmt.Sprintf("bare exit %d", e.Code) }
// ErrBare builds the silent-exit signal with the given code.
func ErrBare(code int) *BareError { return &BareError{Code: code} }
+23
View File
@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output_test
import (
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestExitCodeOfBareError(t *testing.T) {
if got := output.ExitCodeOf(output.ErrBare(3)); got != 3 {
t.Errorf("ExitCodeOf(ErrBare(3)) = %d, want 3", got)
}
}
// TestErrBareReturnsBareError pins that the silent-exit signal is the
// dedicated *output.BareError type, keeping that contract on its own
// narrow signal type.
func TestErrBareReturnsBareError(t *testing.T) {
var _ *output.BareError = output.ErrBare(1)
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
const (
Dim = "\033[2m"
Bold = "\033[1m"
Yellow = "\033[33m"
Cyan = "\033[36m"
Red = "\033[31m"
Green = "\033[32m"
Reset = "\033[0m"
)
+76
View File
@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"encoding/csv"
"fmt"
"io"
"os"
)
// FormatAsCSV formats data as CSV (with header) and writes it to w.
func FormatAsCSV(w io.Writer, data interface{}) {
FormatAsCSVPaginated(w, data, true)
}
// FormatAsCSVPaginated formats data as CSV with pagination awareness.
// When isFirstPage is true, outputs the header row; otherwise only data rows.
func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
fmt.Fprintln(w, "(empty)")
} else {
PrintJson(w, data)
}
return
}
if len(rows) == 0 {
if isFirstPage {
fmt.Fprintln(w, "(empty)")
}
return
}
if !isList {
// Single object: key,value rows
cw := csv.NewWriter(w)
if isFirstPage {
cw.Write([]string{"key", "value"})
}
for _, col := range cols {
cw.Write([]string{col, rows[0][col]})
}
flushCSV(cw)
return
}
writeCSVRows(w, rows, cols, isFirstPage)
}
// writeCSVRows writes CSV data rows (and optionally header) using the given columns.
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) {
cw := csv.NewWriter(w)
if writeHeader {
cw.Write(cols)
}
for _, row := range rows {
record := make([]string, len(cols))
for i, col := range cols {
record[i] = row[col]
}
cw.Write(record)
}
flushCSV(cw)
}
// flushCSV flushes the csv.Writer and reports any write error to stderr.
func flushCSV(cw *csv.Writer) {
cw.Flush()
if err := cw.Error(); err != nil {
fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
}
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
func TestFormatAsCSV_BasicArray(t *testing.T) {
data := []interface{}{
map[string]interface{}{"name": "Alice", "age": float64(30)},
map[string]interface{}{"name": "Bob", "age": float64(25)},
}
var buf bytes.Buffer
FormatAsCSV(&buf, data)
out := buf.String()
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("expected 3 lines (header + 2 rows), got %d:\n%s", len(lines), out)
}
// Header should contain both column names
header := lines[0]
if !strings.Contains(header, "name") || !strings.Contains(header, "age") {
t.Errorf("header should contain 'name' and 'age', got: %s", header)
}
}
func TestFormatAsCSV_RFC4180Escaping(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"text": `hello, "world"`,
},
}
var buf bytes.Buffer
FormatAsCSV(&buf, data)
out := buf.String()
// RFC 4180: fields with commas/quotes are quoted, internal quotes are doubled
if !strings.Contains(out, `"hello, ""world"""`) {
t.Errorf("CSV should properly escape commas and quotes, got:\n%s", out)
}
}
func TestFormatAsCSV_NewlineInValue(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"text": "line1\nline2",
},
}
var buf bytes.Buffer
FormatAsCSV(&buf, data)
out := buf.String()
// RFC 4180: fields with newlines should be quoted
if !strings.Contains(out, `"line1`) {
t.Errorf("CSV should quote fields containing newlines, got:\n%s", out)
}
}
func TestFormatAsCSV_NestedObject(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"user": map[string]interface{}{
"name": "Alice",
},
"id": float64(1),
},
}
var buf bytes.Buffer
FormatAsCSV(&buf, data)
out := buf.String()
if !strings.Contains(out, "user.name") {
t.Errorf("CSV should contain flattened 'user.name' column, got:\n%s", out)
}
}
func TestFormatAsCSV_EmptyArray(t *testing.T) {
data := []interface{}{}
var buf bytes.Buffer
FormatAsCSV(&buf, data)
out := strings.TrimSpace(buf.String())
if out != "(empty)" {
t.Errorf("empty array should output '(empty)', got:\n%s", out)
}
}
func TestFormatAsCSVPaginated_FirstPage(t *testing.T) {
data := []interface{}{
map[string]interface{}{"name": "Alice"},
}
var buf bytes.Buffer
FormatAsCSVPaginated(&buf, data, true)
out := buf.String()
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 2 {
t.Errorf("first page should have header + 1 data row, got %d lines:\n%s", len(lines), out)
}
if lines[0] != "name" {
t.Errorf("first line should be header 'name', got: %s", lines[0])
}
}
func TestFormatAsCSVPaginated_ContinuationPage(t *testing.T) {
data := []interface{}{
map[string]interface{}{"name": "Bob"},
}
var buf bytes.Buffer
FormatAsCSVPaginated(&buf, data, false)
out := buf.String()
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 1 {
t.Errorf("continuation page should have 1 data row, got %d lines:\n%s", len(lines), out)
}
if lines[0] != "Bob" {
t.Errorf("continuation page data should be 'Bob', got: %s", lines[0])
}
}
func TestFormatAsCSV_SingleObject(t *testing.T) {
data := map[string]interface{}{
"name": "Alice",
"age": float64(30),
}
var buf bytes.Buffer
FormatAsCSV(&buf, data)
out := buf.String()
// Single object should render as key,value format
if !strings.Contains(out, "key,value") {
t.Errorf("single object should have key,value header, got:\n%s", out)
}
if !strings.Contains(out, "Alice") {
t.Errorf("output should contain 'Alice', got:\n%s", out)
}
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
)
// ScanResult holds the output of ScanForSafety.
type ScanResult struct {
Alert *extcs.Alert
Blocked bool
BlockErr error
}
// ScanForSafety runs content-safety scanning on the given data.
// cmdPath is the raw cobra CommandPath().
// When MODE=off, no provider registered, or the command is not allowlisted,
// returns a zero ScanResult.
func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
alert, csErr := runContentSafety(cmdPath, data, errOut)
if errors.Is(csErr, errBlocked) {
return ScanResult{
Alert: alert,
Blocked: true,
BlockErr: wrapBlockError(alert),
}
}
return ScanResult{Alert: alert}
}
// wrapBlockError creates a typed error for content-safety block.
func wrapBlockError(alert *extcs.Alert) error {
var matchedRules []string
if alert != nil {
matchedRules = alert.MatchedRules
}
return errs.NewContentSafetyError(errs.SubtypeContentSafety,
"content safety violation detected (rules: %s)", strings.Join(matchedRules, ", ")).
WithRules(matchedRules...).
WithCause(errBlocked)
}
// WriteAlertWarning writes a human-readable content-safety warning to w.
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
if alert == nil {
return
}
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
alert.Provider, strings.Join(alert.MatchedRules, ", "))
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"time"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/envvars"
)
type mode uint8
const (
modeOff mode = iota
modeWarn
modeBlock
)
// scanTimeout caps the content-safety scan so it cannot dominate CLI latency.
// 100 ms is generous for a regex walk of a typical API response (KB-scale JSON);
// larger responses hit maxDepth/maxStringBytes well before this fires.
const scanTimeout = 100 * time.Millisecond
// modeFromEnv reads LARKSUITE_CLI_CONTENT_SAFETY_MODE.
func modeFromEnv(errOut io.Writer) mode {
raw := strings.TrimSpace(os.Getenv(envvars.CliContentSafetyMode))
if raw == "" {
return modeOff
}
switch strings.ToLower(raw) {
case "off":
return modeOff
case "warn":
return modeWarn
case "block":
return modeBlock
default:
fmt.Fprintf(errOut,
"warning: unknown %s value %q, falling back to off\n",
envvars.CliContentSafetyMode, raw)
return modeOff
}
}
// normalizeCommandPath converts cobra CommandPath() to dotted form.
// "lark-cli im +messages-search" -> "im.messages_search"
func normalizeCommandPath(cobraPath string) string {
segs := strings.Fields(cobraPath)
if len(segs) <= 1 {
return ""
}
segs = segs[1:]
for i, s := range segs {
s = strings.TrimPrefix(s, "+")
s = strings.ReplaceAll(s, "-", "_")
segs[i] = s
}
return strings.Join(segs, ".")
}
var errBlocked = fmt.Errorf("content safety blocked")
// runContentSafety orchestrates the scan: mode check -> provider -> scan with timeout + panic recovery.
func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Alert, error) {
m := modeFromEnv(errOut)
if m == modeOff {
return nil, nil
}
p := extcs.GetProvider()
if p == nil {
return nil, nil
}
cmdPath := normalizeCommandPath(cobraPath)
if cmdPath == "" {
return nil, nil
}
type result struct {
alert *extcs.Alert
err error
}
ch := make(chan result, 1)
ctx, cancel := context.WithTimeout(context.Background(), scanTimeout)
defer cancel()
// Give the goroutine its own writer so it cannot race on errOut after timeout.
// On success, we copy any provider notices to the real errOut.
// On timeout, the buffer is owned by the goroutine until it finishes; no shared access.
scanErrBuf := &bytes.Buffer{}
go func() {
defer func() {
if r := recover(); r != nil {
ch <- result{nil, fmt.Errorf("content safety panic: %v", r)}
}
}()
a, e := p.Scan(ctx, extcs.ScanRequest{Path: cmdPath, Data: data, ErrOut: scanErrBuf})
ch <- result{a, e}
}()
var res result
select {
case res = <-ch:
if scanErrBuf.Len() > 0 {
_, _ = io.Copy(errOut, scanErrBuf)
}
case <-ctx.Done():
return nil, nil // timeout, fail-open; scanErrBuf stays with the goroutine
}
if res.err != nil {
fmt.Fprintf(errOut, "warning: content safety scan error: %v\n", res.err)
return nil, nil // fail-open
}
if res.alert == nil {
return nil, nil
}
if m == modeBlock {
return res.alert, errBlocked
}
return res.alert, nil
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"testing"
)
func TestModeFromEnv(t *testing.T) {
tests := []struct {
name string
envVal string
want mode
wantWarn bool
}{
{"empty", "", modeOff, false},
{"off", "off", modeOff, false},
{"OFF", "OFF", modeOff, false},
{"warn", "warn", modeWarn, false},
{"WARN", "WARN", modeWarn, false},
{"block", "block", modeBlock, false},
{"unknown", "banana", modeOff, true},
{"whitespace", " warn ", modeWarn, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.envVal)
var buf bytes.Buffer
got := modeFromEnv(&buf)
if got != tt.want {
t.Errorf("modeFromEnv() = %d, want %d", got, tt.want)
}
if tt.wantWarn && buf.Len() == 0 {
t.Error("expected stderr warning")
}
if !tt.wantWarn && buf.Len() > 0 {
t.Errorf("unexpected stderr: %s", buf.String())
}
})
}
}
func TestNormalizeCommandPath(t *testing.T) {
tests := []struct {
input string
want string
}{
{"lark-cli im +messages-search", "im.messages_search"},
{"lark-cli drive upload +file", "drive.upload.file"},
{"lark-cli api GET /path", "api.GET./path"},
{"lark-cli", ""},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeCommandPath(tt.input)
if got != tt.want {
t.Errorf("normalizeCommandPath(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
)
// mockProvider is a test provider that returns a configurable alert.
type mockProvider struct {
name string
alert *extcs.Alert
err error
}
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
return m.alert, m.err
}
func TestScanForSafety_ModeOff(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +messages-search", map[string]any{"text": "inject"}, &buf)
if result.Alert != nil || result.Blocked {
t.Error("mode=off should produce zero ScanResult")
}
}
func TestScanForSafety_ModeWarn_WithAlert(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
alert := &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}}
mp := &mockProvider{name: "mock", alert: alert}
// Register mock provider (save and restore)
extcs.Register(mp)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Alert == nil {
t.Fatal("expected non-nil alert in warn mode")
}
if result.Blocked {
t.Error("warn mode should not block")
}
if result.BlockErr != nil {
t.Error("warn mode should not have BlockErr")
}
}
func TestScanForSafety_ModeBlock_WithAlert(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
alert := &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}}
mp := &mockProvider{name: "mock", alert: alert}
extcs.Register(mp)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if !result.Blocked {
t.Error("block mode with alert should set Blocked=true")
}
if result.BlockErr == nil {
t.Error("block mode with alert should have BlockErr")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(result.BlockErr, &safetyErr) {
t.Fatalf("BlockErr should be *ContentSafetyError, got %T", result.BlockErr)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Errorf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if got := ExitCodeOf(result.BlockErr); got != ExitContentSafety {
t.Errorf("exit code = %d, want %d", got, ExitContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "r1" {
t.Errorf("rules = %v, want [r1]", safetyErr.Rules)
}
if !errors.Is(result.BlockErr, errBlocked) {
t.Error("BlockErr should preserve errBlocked cause")
}
}
func TestScanForSafety_NoProvider(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Alert != nil || result.Blocked {
t.Error("no provider should produce zero ScanResult")
}
}
func TestScanForSafety_ScanError_FailOpen(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
extcs.Register(mp)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked {
t.Error("scan error should fail-open, not block")
}
if !strings.Contains(buf.String(), "scan error") {
t.Errorf("expected warning on stderr, got: %s", buf.String())
}
}
func TestScanForSafety_SlowProvider_Timeout_FailOpen(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
slow := &slowProvider{}
extcs.Register(slow)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked {
t.Error("slow provider should fail-open on timeout, not block")
}
if result.Alert != nil {
t.Error("slow provider should return nil alert on timeout")
}
}
// slowProvider blocks for longer than scanTimeout to trigger the timeout path.
type slowProvider struct{}
func (s *slowProvider) Name() string { return "slow" }
func (s *slowProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(200 * time.Millisecond):
return &extcs.Alert{Provider: "slow", MatchedRules: []string{"never"}}, nil
}
}
func TestWriteAlertWarning(t *testing.T) {
alert := &extcs.Alert{Provider: "regex", MatchedRules: []string{"r1", "r2"}}
var buf bytes.Buffer
WriteAlertWarning(&buf, alert)
got := buf.String()
if !strings.Contains(got, "r1") || !strings.Contains(got, "r2") {
t.Errorf("warning should contain rule IDs, got: %s", got)
}
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
// Envelope is the standard success response wrapper.
type Envelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
ContentSafetyAlert interface{} `json:"_content_safety_alert,omitempty"`
Notice map[string]interface{} `json:"_notice,omitempty"`
}
// Meta carries optional metadata in envelope responses.
type Meta struct {
Count int `json:"count,omitempty"`
Rollback string `json:"rollback,omitempty"`
}
// PendingNotice, if set, returns system-level notices to inject as the
// "_notice" field in JSON output envelopes. Set by cmd/root.go.
// Returns nil when there is nothing to report.
var PendingNotice func() map[string]interface{}
// GetNotice returns the current pending notice for struct-based callers.
// Returns nil when there is nothing to report.
func GetNotice() map[string]interface{} {
if PendingNotice == nil {
return nil
}
return PendingNotice()
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import "io"
// SuccessEnvelopeOptions configures the shortcut-compatible success envelope.
type SuccessEnvelopeOptions struct {
CommandPath string
Identity string
JqExpr string
Out io.Writer
ErrOut io.Writer
}
// SuccessEnvelopeData extracts the business payload for the standard success
// envelope from a Lark API response. Outer code/msg fields are transport
// protocol details and are intentionally not exposed as business data.
func SuccessEnvelopeData(result interface{}) interface{} {
m, ok := result.(map[string]interface{})
if !ok {
return map[string]interface{}{}
}
data, ok := m["data"]
if !ok || data == nil {
return map[string]interface{}{}
}
return data
}
// WriteSuccessEnvelope emits the standard success envelope used by shortcuts.
// JSON output carries content-safety alerts inside the envelope. When jq is
// applied, the alert may be filtered away, so warn mode also writes stderr.
func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
scanResult := ScanForSafety(opts.CommandPath, data, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
env := Envelope{
OK: true,
Identity: opts.Identity,
Data: data,
Notice: GetNotice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JqExpr != "" {
if scanResult.Alert != nil && opts.ErrOut != nil {
WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
return JqFilter(opts.Out, env, opts.JqExpr)
}
PrintJson(opts.Out, env)
return nil
}
+173
View File
@@ -0,0 +1,173 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
)
func TestSuccessEnvelopeData_ExtractsBusinessData(t *testing.T) {
result := map[string]interface{}{
"code": float64(0),
"msg": "ok",
"data": map[string]interface{}{"id": "1"},
}
got := SuccessEnvelopeData(result)
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("business data type = %T, want map", got)
}
if m["id"] != "1" {
t.Fatalf("id = %v, want 1", m["id"])
}
if _, ok := m["code"]; ok {
t.Fatal("business data must not contain outer code")
}
}
func TestSuccessEnvelopeData_MissingDataUsesEmptyObject(t *testing.T) {
got := SuccessEnvelopeData(map[string]interface{}{"code": float64(0), "msg": "ok"})
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("business data type = %T, want map", got)
}
if len(m) != 0 {
t.Fatalf("business data = %#v, want empty object", m)
}
}
func TestSuccessEnvelopeData_NilDataUsesEmptyObject(t *testing.T) {
got := SuccessEnvelopeData(map[string]interface{}{"code": float64(0), "msg": "ok", "data": nil})
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("business data type = %T, want map", got)
}
if len(m) != 0 {
t.Fatalf("business data = %#v, want empty object", m)
}
}
func TestWriteSuccessEnvelope_PrintsShortcutCompatibleEnvelope(t *testing.T) {
var out strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
Identity: "bot",
Out: &out,
})
if err != nil {
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
}
if env["ok"] != true || env["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", env)
}
data, ok := env["data"].(map[string]interface{})
if !ok || data["id"] != "1" {
t.Fatalf("unexpected data payload: %#v", env["data"])
}
if _, ok := env["code"]; ok {
t.Fatalf("output leaked protocol field code: %#v", env)
}
if _, ok := env["msg"]; ok {
t.Fatalf("output leaked protocol field msg: %#v", env)
}
if _, ok := env["_content_safety_alert"]; ok {
t.Fatalf("output should omit empty content-safety alert: %#v", env)
}
}
func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
var out strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
Identity: "bot",
JqExpr: ".data.id",
Out: &out,
})
if err != nil {
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
}
if strings.TrimSpace(out.String()) != "1" {
t.Fatalf("jq output = %q, want %q", out.String(), "1")
}
}
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&mockProvider{
name: "mock",
alert: &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}},
})
t.Cleanup(func() { extcs.Register(nil) })
var out strings.Builder
var errOut strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
CommandPath: "lark-cli im +test",
Identity: "bot",
JqExpr: ".data.id",
Out: &out,
ErrOut: &errOut,
})
if err != nil {
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
}
if strings.TrimSpace(out.String()) != "1" {
t.Fatalf("jq output = %q, want %q", out.String(), "1")
}
if !strings.Contains(errOut.String(), "warning: content safety alert from mock") {
t.Fatalf("expected content safety warning on stderr, got: %s", errOut.String())
}
if !strings.Contains(errOut.String(), "r1") {
t.Fatalf("expected rule in stderr warning, got: %s", errOut.String())
}
}
func TestWriteSuccessEnvelope_BlockModeReturnsTypedErrorWithoutStdout(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(&mockProvider{
name: "mock",
alert: &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}},
})
t.Cleanup(func() { extcs.Register(nil) })
var out strings.Builder
var errOut strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
CommandPath: "lark-cli im +test",
Identity: "bot",
Out: &out,
ErrOut: &errOut,
})
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "r1" {
t.Fatalf("rules = %v, want [r1]", safetyErr.Rules)
}
if !errors.Is(err, errBlocked) {
t.Fatal("content safety error should preserve errBlocked cause")
}
if out.String() != "" {
t.Fatalf("stdout should stay empty on block, got: %s", out.String())
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"encoding/json"
"fmt"
"io"
"github.com/larksuite/cli/errs"
)
// PartialFailureError is the exit signal for a batch / multi-status command that
// has already written an ok:false result envelope to stdout. The per-item
// outcomes are the primary, machine-readable output and live on stdout, so the
// dispatcher sets only the exit code and writes nothing to stderr.
//
// It is deliberately distinct from ErrBare (the stdout-carries-the-answer
// silent-exit signal) so that contract stays narrow, and from a typed *errs.XxxError
// (which owns the stderr error envelope): a partial failure is a result, not an
// error envelope.
type PartialFailureError struct {
Code int
}
func (e *PartialFailureError) Error() string {
return fmt.Sprintf("partial failure (exit %d)", e.Code)
}
// PartialFailure builds the partial-failure exit signal with the given code.
func PartialFailure(code int) *PartialFailureError {
return &PartialFailureError{Code: code}
}
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
// Each typed error owns its wire shape via its own struct tags: Problem fields
// are promoted to the top level through embedding, and extension fields
// (MissingScopes, ChallengeURL, etc.) sit alongside as siblings — not inside
// a `detail` sub-object.
//
// Two-stage write:
//
// 1. Serialize the envelope into an in-memory buffer. If serialization
// fails, return false so the dispatcher handles it via its signal /
// usage-error branches; nothing is written to w.
// 2. Best-effort write of the serialized bytes to w. A partial write is
// accepted (return value still true): the typed exit code has already
// been determined upstream by handleRootError calling ExitCodeOf(err)
// before this writer runs, so a torn envelope on stderr must not
// downgrade the caller's typed exit (3/4/6/10) to plain 1. Consumers
// parse-or-skip on malformed JSON.
//
// Returns true when err was a typed error and serialization succeeded.
// Returns false only when err carries no Problem (the dispatcher then handles
// it via its signal / usage-error branches) or when JSON encoding itself failed.
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
typed, ok := errs.UnwrapTypedError(err)
if !ok {
return false
}
env := typedEnvelope{
OK: false,
Identity: identity,
Error: typed,
Notice: GetNotice(),
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if encErr := enc.Encode(env); encErr != nil {
// Encoding failed — emit nothing here; the dispatcher's fall-through
// branches still surface the error, so stderr is never blank.
return false
}
// Best-effort write. Partial-write does not downgrade the success status:
// the dispatcher has already captured ExitCodeOf(err) before calling us,
// and a torn stderr is preferable to falling through to the plain
// "Error:" path with exit 1.
_, _ = w.Write(buf.Bytes())
return true
}
// typedEnvelope wraps a typed error for wire emission. Error is `error` so the
// underlying typed error's own json tags determine the inner shape via
// encoding/json reflection; Notice mirrors the success Envelope's notice (see
// GetNotice in envelope.go).
type typedEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error error `json:"error"`
Notice map[string]interface{} `json:"_notice,omitempty"`
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"io"
"testing"
"github.com/larksuite/cli/errs"
)
// failingWriter writes up to limit bytes then returns io.ErrShortWrite on
// the write that would push past the limit. Used to simulate a stderr that
// dies mid-envelope.
type failingWriter struct {
limit int
n int
}
func (f *failingWriter) Write(p []byte) (int, error) {
if f.n+len(p) > f.limit {
canWrite := f.limit - f.n
if canWrite < 0 {
canWrite = 0
}
f.n += canWrite
return canWrite, io.ErrShortWrite
}
f.n += len(p)
return len(p), nil
}
// TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus pins that
// when serialization succeeds but the underlying write fails mid-envelope,
// WriteTypedErrorEnvelope returns true so the dispatcher honors the typed
// exit code instead of reclassifying the error. Exit code is preserved
// separately by handleRootError computing ExitCodeOf(err) before the write.
func TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus(t *testing.T) {
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
w := &failingWriter{limit: 20} // dies mid-envelope
if ok := WriteTypedErrorEnvelope(w, err, "user"); !ok {
t.Error("partial write must return true; exit code is preserved separately")
}
}
func TestGetNotice(t *testing.T) {
// Nil PendingNotice → nil
origNotice := PendingNotice
PendingNotice = nil
if got := GetNotice(); got != nil {
t.Errorf("expected nil, got %v", got)
}
// With PendingNotice → returns value
PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"update": "test"}
}
got := GetNotice()
if got == nil || got["update"] != "test" {
t.Errorf("expected {update: test}, got %v", got)
}
// PendingNotice returns nil → nil
PendingNotice = func() map[string]interface{} { return nil }
if got := GetNotice(); got != nil {
t.Errorf("expected nil, got %v", got)
}
PendingNotice = origNotice
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"errors"
"github.com/larksuite/cli/errs"
)
// Fine-grained error types (permission, not_found, rate_limit, etc.)
// are communicated via the JSON error envelope's "type" field,
// not via exit codes.
const (
ExitOK = 0 // 成功
ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit
ExitValidation = 2 // 参数校验失败
ExitAuth = 3 // 认证失败(token 无效 / 过期),或登录成功但请求 scopes 未全部授予
ExitNetwork = 4 // 网络错误(连接超时、DNS 解析失败等)
ExitInternal = 5 // 内部错误(不应发生)
ExitContentSafety = 6 // content safety violation (block mode)
ExitConfirmationRequired = 10 // 高风险操作需要 --yes 确认(agent 协议信号)
)
// ExitCodeForCategory maps an errs.Category to the shell exit code.
// Multiple categories may share an exit code (Authentication / Authorization /
// Config all map to 3), so the relationship is many-to-one.
func ExitCodeForCategory(cat errs.Category) int {
switch cat {
case errs.CategoryValidation:
return ExitValidation
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig:
return ExitAuth
case errs.CategoryNetwork:
return ExitNetwork
case errs.CategoryAPI:
return ExitAPI
case errs.CategoryPolicy:
return ExitContentSafety
case errs.CategoryInternal:
return ExitInternal
case errs.CategoryConfirmation:
return ExitConfirmationRequired
}
return ExitInternal
}
// ExitCodeOf returns the shell exit code for any error.
// - typed errors (*errs.PermissionError, *errs.APIError, *errs.ConfigError,
// *errs.AuthenticationError, ...) → routed by Category
// - *PartialFailureError / *BareError signals → their own Code field
// - untyped → ExitInternal
func ExitCodeOf(err error) int {
if err == nil {
return ExitOK
}
if _, ok := errs.ProblemOf(err); ok {
return ExitCodeForCategory(errs.CategoryOf(err))
}
var pfErr *PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
var bare *BareError
if errors.As(err, &bare) {
return bare.Code
}
return ExitInternal
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestExitCodeForCategory(t *testing.T) {
cases := []struct {
name string
cat errs.Category
want int
}{
{"validation", errs.CategoryValidation, 2},
{"authentication", errs.CategoryAuthentication, 3},
{"authorization", errs.CategoryAuthorization, 3},
{"config", errs.CategoryConfig, 3},
{"network", errs.CategoryNetwork, 4},
{"api", errs.CategoryAPI, 1},
{"policy", errs.CategoryPolicy, 6},
{"internal", errs.CategoryInternal, 5},
{"confirmation", errs.CategoryConfirmation, 10},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ExitCodeForCategory(tc.cat); got != tc.want {
t.Errorf("ExitCodeForCategory(%q) = %d, want %d", tc.cat, got, tc.want)
}
})
}
}
func TestExitCodeForCategory_UnknownDefaults(t *testing.T) {
if got := ExitCodeForCategory(errs.Category("not_a_real_category")); got != ExitInternal {
t.Errorf("ExitCodeForCategory(unknown) = %d, want %d (ExitInternal)", got, ExitInternal)
}
}
func TestExitCodeOf_Nil(t *testing.T) {
if got := ExitCodeOf(nil); got != 0 {
t.Errorf("ExitCodeOf(nil) = %d, want 0", got)
}
}
func TestExitCodeOf_PermissionError(t *testing.T) {
err := &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}}
if got := ExitCodeOf(err); got != 3 {
t.Errorf("ExitCodeOf(PermissionError) = %d, want 3", got)
}
}
func TestExitCodeOf_APIError(t *testing.T) {
err := &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}}
if got := ExitCodeOf(err); got != 1 {
t.Errorf("ExitCodeOf(APIError) = %d, want 1", got)
}
}
func TestExitCodeOf_UntypedFallsBackToInternal(t *testing.T) {
if got := ExitCodeOf(fmt.Errorf("plain")); got != 5 {
t.Errorf("ExitCodeOf(plain) = %d, want 5 (untyped → CategoryInternal → ExitInternal)", got)
}
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"sort"
"unicode/utf8"
)
const maxFlattenDepth = 3
type flatEntry struct {
Key string
Value string
}
// flattenObject flattens a nested object into dot-notation key-value pairs.
// Objects nested beyond maxFlattenDepth levels are serialized as JSON strings.
// Keys are sorted alphabetically for deterministic column order.
func flattenObject(obj map[string]interface{}, prefix string, depth int) []flatEntry {
keys := make([]string, 0, len(obj))
for k := range obj {
keys = append(keys, k)
}
sort.Strings(keys)
var entries []flatEntry
for _, k := range keys {
v := obj[k]
key := k
if prefix != "" {
key = prefix + "." + k
}
switch val := v.(type) {
case map[string]interface{}:
if depth+1 >= maxFlattenDepth {
entries = append(entries, flatEntry{Key: key, Value: cellStr(val)})
} else {
entries = append(entries, flattenObject(val, key, depth+1)...)
}
default:
entries = append(entries, flatEntry{Key: key, Value: cellStr(v)})
}
}
return entries
}
// collectColumns collects column names from all rows (union set),
// preserving first-occurrence order.
func collectColumns(rows [][]flatEntry) []string {
seen := map[string]bool{}
var cols []string
for _, row := range rows {
for _, e := range row {
if !seen[e.Key] {
seen[e.Key] = true
cols = append(cols, e.Key)
}
}
}
return cols
}
// rowMap converts a slice of flatEntry into a map for column lookup.
func rowMap(entries []flatEntry) map[string]string {
m := make(map[string]string, len(entries))
for _, e := range entries {
m[e.Key] = e.Value
}
return m
}
// runeWidth returns the display width of a rune.
// CJK characters and some symbols are double-width.
func runeWidth(r rune) int {
if r == utf8.RuneError {
return 1
}
// CJK Unified Ideographs, CJK Compatibility Ideographs, etc.
if (r >= 0x1100 && r <= 0x115F) || // Hangul Jamo
r == 0x2329 || r == 0x232A ||
(r >= 0x2E80 && r <= 0x303E) || // CJK Radicals, Kangxi, CJK Symbols
(r >= 0x3040 && r <= 0x33BF) || // Hiragana, Katakana, Bopomofo, etc.
(r >= 0x3400 && r <= 0x4DBF) || // CJK Unified Ideographs Extension A
(r >= 0x4E00 && r <= 0xA4CF) || // CJK Unified Ideographs, Yi
(r >= 0xA960 && r <= 0xA97C) || // Hangul Jamo Extended-A
(r >= 0xAC00 && r <= 0xD7A3) || // Hangul Syllables
(r >= 0xF900 && r <= 0xFAFF) || // CJK Compatibility Ideographs
(r >= 0xFE10 && r <= 0xFE6F) || // CJK Compatibility Forms, Small Forms
(r >= 0xFF01 && r <= 0xFF60) || // Fullwidth Forms
(r >= 0xFFE0 && r <= 0xFFE6) || // Fullwidth Signs
(r >= 0x1F300 && r <= 0x1F9FF) || // Emoji (Miscellaneous Symbols and Pictographs, Emoticons, etc.)
(r >= 0x20000 && r <= 0x2FFFF) || // CJK Unified Ideographs Extension B-F
(r >= 0x30000 && r <= 0x3FFFF) { // CJK Unified Ideographs Extension G+
return 2
}
return 1
}
// stringWidth returns the display width of a string.
func stringWidth(s string) int {
w := 0
for _, r := range s {
w += runeWidth(r)
}
return w
}
// truncateToWidth truncates a string to fit within maxWidth display columns.
// If truncated, appends "…".
func truncateToWidth(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
w := 0
for i, r := range s {
rw := runeWidth(r)
if w+rw > maxWidth {
return s[:i] + "…"
}
w += rw
}
return s
}
// flattenItem flattens a single item (object or other) into flatEntry pairs.
func flattenItem(item interface{}) []flatEntry {
if obj, ok := item.(map[string]interface{}); ok {
return flattenObject(obj, "", 0)
}
return []flatEntry{{Key: "value", Value: cellStr(item)}}
}
// prepareRows converts a data value into flattened rows and column names.
// Returns rows (as maps), columns, and whether the data was a list.
func prepareRows(data interface{}) (rows []map[string]string, cols []string, isList bool) {
items := extractArray(data)
if items == nil {
// Single object
if obj, ok := data.(map[string]interface{}); ok {
entries := flattenObject(obj, "", 0)
rm := rowMap(entries)
flatRows := [][]flatEntry{entries}
return []map[string]string{rm}, collectColumns(flatRows), false
}
return nil, nil, false
}
isList = true
var flatRows [][]flatEntry
for _, item := range items {
entries := flattenItem(item)
flatRows = append(flatRows, entries)
rows = append(rows, rowMap(entries))
}
cols = collectColumns(flatRows)
return rows, cols, isList
}
// extractArray extracts an array from data, or returns nil.
func extractArray(data interface{}) []interface{} {
if arr, ok := data.([]interface{}); ok {
return arr
}
return nil
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"testing"
)
func TestFlattenObjectSimple(t *testing.T) {
obj := map[string]interface{}{
"name": "Alice",
"age": float64(30),
}
entries := flattenObject(obj, "", 0)
m := rowMap(entries)
if m["name"] != "Alice" {
t.Errorf("name = %q, want %q", m["name"], "Alice")
}
if m["age"] != "30" {
t.Errorf("age = %q, want %q", m["age"], "30")
}
}
func TestFlattenObjectNested(t *testing.T) {
obj := map[string]interface{}{
"user": map[string]interface{}{
"name": "Alice",
"addr": map[string]interface{}{
"city": "Beijing",
},
},
}
entries := flattenObject(obj, "", 0)
m := rowMap(entries)
if m["user.name"] != "Alice" {
t.Errorf("user.name = %q, want %q", m["user.name"], "Alice")
}
if m["user.addr.city"] != "Beijing" {
t.Errorf("user.addr.city = %q, want %q", m["user.addr.city"], "Beijing")
}
}
func TestFlattenObjectDeepLimit(t *testing.T) {
// Create depth=4 nesting — should serialize the innermost object as JSON
obj := map[string]interface{}{
"a": map[string]interface{}{
"b": map[string]interface{}{
"c": map[string]interface{}{
"d": "deep",
},
},
},
}
entries := flattenObject(obj, "", 0)
m := rowMap(entries)
// depth 0 → a (map), depth 1 → b (map), depth 2 → c (map), depth 3 ≥ maxFlattenDepth → serialize
if v, ok := m["a.b.c"]; !ok {
t.Errorf("expected key a.b.c, got keys: %v", m)
} else if v != `{"d":"deep"}` {
t.Errorf("a.b.c = %q, want JSON string", v)
}
}
func TestFlattenObjectArrayLeaf(t *testing.T) {
obj := map[string]interface{}{
"tags": []interface{}{"a", "b"},
}
entries := flattenObject(obj, "", 0)
m := rowMap(entries)
if m["tags"] != `["a","b"]` {
t.Errorf("tags = %q, want %q", m["tags"], `["a","b"]`)
}
}
func TestFlattenObjectNilValue(t *testing.T) {
obj := map[string]interface{}{
"empty": nil,
}
entries := flattenObject(obj, "", 0)
m := rowMap(entries)
if m["empty"] != "" {
t.Errorf("empty = %q, want %q", m["empty"], "")
}
}
func TestCollectColumns(t *testing.T) {
rows := [][]flatEntry{
{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}},
{{Key: "b", Value: "3"}, {Key: "c", Value: "4"}},
}
cols := collectColumns(rows)
// Should contain a, b, c (union)
colSet := map[string]bool{}
for _, c := range cols {
colSet[c] = true
}
for _, expected := range []string{"a", "b", "c"} {
if !colSet[expected] {
t.Errorf("missing column %q in %v", expected, cols)
}
}
if len(cols) != 3 {
t.Errorf("got %d columns, want 3", len(cols))
}
}
func TestTruncateToWidth(t *testing.T) {
tests := []struct {
input string
maxWidth int
want string
}{
{"hello", 10, "hello"},
{"hello", 5, "hello"},
{"hello", 4, "hell…"},
{"hello", 3, "hel…"},
{"hello", 1, "h…"},
{"hello", 0, ""},
// CJK: each char is width 2
{"你好世界", 8, "你好世界"},
{"你好世界", 6, "你好世…"},
{"你好世界", 4, "你好…"},
{"你好世界", 3, "你…"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := truncateToWidth(tt.input, tt.maxWidth)
if got != tt.want {
t.Errorf("truncateToWidth(%q, %d) = %q, want %q", tt.input, tt.maxWidth, got, tt.want)
}
})
}
}
func TestStringWidth(t *testing.T) {
tests := []struct {
input string
want int
}{
{"hello", 5},
{"你好", 4},
{"ab你好cd", 8},
{"", 0},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := stringWidth(tt.input)
if got != tt.want {
t.Errorf("stringWidth(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
+196
View File
@@ -0,0 +1,196 @@
// 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) {
data = toGeneric(data)
switch format {
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
PrintNdjson(w, items)
} else {
PrintNdjson(w, data)
}
case FormatTable:
items := ExtractItems(data)
if items != nil {
FormatAsTable(w, items)
} else {
FormatAsTable(w, data)
}
case FormatCSV:
items := ExtractItems(data)
if items != nil {
FormatAsCSV(w, items)
} else {
FormatAsCSV(w, data)
}
default: // FormatJSON
PrintJson(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{}) {
switch pf.Format {
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
PrintNdjson(pf.W, arr)
} else {
PrintNdjson(pf.W, data)
}
case FormatTable:
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
widths := computeColumnWidths(rows, cols)
if isFirst {
writeHeader(w, cols, widths)
}
for _, row := range rows {
writeRow(w, row, cols, widths)
}
})
case FormatCSV:
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
writeCSVRows(w, rows, cols, isFirst)
})
}
}
// 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)) {
rows, pageCols, isList := prepareRows(data)
if len(rows) == 0 {
if pf.isFirstPage && isList {
fmt.Fprintln(pf.W, "(empty)")
}
return
}
if pf.isFirstPage {
// Lock columns from first page
pf.cols = pageCols
pf.isFirstPage = false
emit(pf.W, rows, pf.cols, true)
} else {
// Reuse first page's columns — missing keys become empty, extra keys ignored
emit(pf.W, rows, pf.cols, false)
}
}
+301
View File
@@ -0,0 +1,301 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
func TestFormatValue_JSON(t *testing.T) {
data := map[string]interface{}{"name": "Alice"}
var buf bytes.Buffer
FormatValue(&buf, data, FormatJSON)
out := buf.String()
// Should be pretty-printed JSON
if !strings.Contains(out, `"name"`) {
t.Errorf("JSON output should contain field name, got:\n%s", out)
}
if !strings.Contains(out, "Alice") {
t.Errorf("JSON output should contain value, got:\n%s", out)
}
}
func TestFormatValue_NDJSON(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": float64(1)},
map[string]interface{}{"id": float64(2)},
},
},
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatNDJSON)
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("NDJSON should output 2 lines, got %d:\n%s", len(lines), buf.String())
}
for _, line := range lines {
var obj map[string]interface{}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
t.Errorf("each NDJSON line should be valid JSON: %s", line)
}
}
}
func TestFormatValue_Table(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice"},
},
},
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatTable)
out := buf.String()
if !strings.Contains(out, "name") {
t.Errorf("table output should contain 'name' header, got:\n%s", out)
}
if !strings.Contains(out, "Alice") {
t.Errorf("table output should contain 'Alice', got:\n%s", out)
}
}
func TestFormatValue_CSV(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice"},
},
},
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatCSV)
out := buf.String()
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("CSV should have header + 1 row, got %d lines:\n%s", len(lines), out)
}
if lines[0] != "name" {
t.Errorf("CSV header should be 'name', got: %s", lines[0])
}
if lines[1] != "Alice" {
t.Errorf("CSV row should be 'Alice', got: %s", lines[1])
}
}
func TestPaginatedFormatter_JSON(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatJSON)
pf.FormatPage([]interface{}{
map[string]interface{}{"id": float64(1)},
map[string]interface{}{"id": float64(2)},
})
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Errorf("paginated JSON should emit 2 lines (NDJSON), got %d:\n%s", len(lines), buf.String())
}
}
func TestPaginatedFormatter_NDJSON(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatNDJSON)
pf.FormatPage([]interface{}{map[string]interface{}{"id": float64(1)}})
out := strings.TrimSpace(buf.String())
var obj map[string]interface{}
if err := json.Unmarshal([]byte(out), &obj); err != nil {
t.Errorf("NDJSON paginated output should be valid JSON: %s", out)
}
}
func TestPaginatedFormatter_Table(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatTable)
page1 := []interface{}{map[string]interface{}{"name": "Alice"}}
page2 := []interface{}{map[string]interface{}{"name": "Bob"}}
pf.FormatPage(page1)
out1 := buf.String()
if !strings.Contains(out1, "─") {
t.Error("first table page should contain separator")
}
buf.Reset()
pf.FormatPage(page2)
out2 := buf.String()
if strings.Contains(out2, "─") {
t.Error("continuation table page should not contain separator")
}
if !strings.Contains(out2, "Bob") {
t.Error("continuation table page should contain data")
}
}
func TestPaginatedFormatter_CSV(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatCSV)
page1 := []interface{}{map[string]interface{}{"name": "Alice"}}
page2 := []interface{}{map[string]interface{}{"name": "Bob"}}
pf.FormatPage(page1)
lines1 := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines1) != 2 {
t.Errorf("first CSV page should have header + data, got %d lines", len(lines1))
}
buf.Reset()
pf.FormatPage(page2)
lines2 := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines2) != 1 {
t.Errorf("continuation CSV page should have only data, got %d lines", len(lines2))
}
}
func TestPaginatedFormatter_ColumnConsistency(t *testing.T) {
// Page 1 has {a, b}, page 2 has {a, b, c} — c should be ignored in CSV
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatCSV)
pf.FormatPage([]interface{}{map[string]interface{}{"a": "1", "b": "2"}})
header := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")[0]
buf.Reset()
pf.FormatPage([]interface{}{map[string]interface{}{"a": "3", "b": "4", "c": "5"}})
dataLine := strings.TrimRight(buf.String(), "\n")
// Header and data should have same number of columns
headerCols := strings.Count(header, ",") + 1
dataCols := strings.Count(dataLine, ",") + 1
if headerCols != dataCols {
t.Errorf("column count mismatch: header has %d, data has %d\nheader: %s\ndata: %s",
headerCols, dataCols, header, dataLine)
}
}
func TestExtractItems(t *testing.T) {
// Standard Lark response
data := map[string]interface{}{
"code": float64(0),
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": float64(1)},
map[string]interface{}{"id": float64(2)},
},
"has_more": true,
"page_token": "abc",
},
}
items := ExtractItems(data)
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
// Different array field
data2 := map[string]interface{}{
"data": map[string]interface{}{
"members": []interface{}{
map[string]interface{}{"user_id": "u1"},
},
},
}
items2 := ExtractItems(data2)
if len(items2) != 1 {
t.Fatalf("expected 1 member, got %d", len(items2))
}
// Already an array
arr := []interface{}{"a", "b"}
items3 := ExtractItems(arr)
if len(items3) != 2 {
t.Fatalf("expected 2 items from raw array, got %d", len(items3))
}
// Non-response
items4 := ExtractItems("string")
if items4 != nil {
t.Fatalf("expected nil for non-response, got %v", items4)
}
// No data field and no array field
items5 := ExtractItems(map[string]interface{}{"foo": "bar"})
if items5 != nil {
t.Fatalf("expected nil for no data/array field, got %v", items5)
}
// Direct map with array field (shortcut data like {"members":[…], "total":5})
directMap := map[string]interface{}{
"members": []interface{}{map[string]interface{}{"name": "Alice"}},
"total": float64(1),
"has_more": false,
"page_token": "",
}
items6 := ExtractItems(directMap)
if len(items6) != 1 {
t.Fatalf("expected 1 item from direct map, got %d", len(items6))
}
// Direct map — plain array passed directly (e.g. calendar freebusy items)
plainArr := []interface{}{
map[string]interface{}{"start": "10:00", "end": "11:00"},
}
items7 := ExtractItems(plainArr)
if len(items7) != 1 {
t.Fatalf("expected 1 item from plain array, got %d", len(items7))
}
}
func TestFormatValue_LegacyFormats(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice"},
},
},
}
// "data" parses to FormatJSON with ok=false
dataFmt, dataOK := ParseFormat("data")
if dataOK {
t.Error("ParseFormat('data') should return ok=false")
}
var buf2 bytes.Buffer
FormatValue(&buf2, data, dataFmt)
out2 := buf2.String()
if !strings.Contains(out2, "items") {
t.Errorf("ParseFormat('data') → JSON should output full response, got:\n%s", out2)
}
// unknown format parses to FormatJSON with ok=false
fooFmt, fooOK := ParseFormat("foobar")
if fooOK {
t.Error("ParseFormat('foobar') should return ok=false")
}
var buf3 bytes.Buffer
FormatValue(&buf3, data, fooFmt)
out3 := buf3.String()
if !strings.Contains(out3, "items") {
t.Errorf("ParseFormat('foobar') → JSON should output full response, got:\n%s", out3)
}
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import "strings"
// Format represents an output format type.
type Format int
const (
FormatJSON Format = iota
FormatNDJSON
FormatTable
FormatCSV
)
// ParseFormat parses a format string into a Format value.
// The second return value is false if the format string was not recognized,
// in which case FormatJSON is returned as default.
func ParseFormat(s string) (Format, bool) {
switch strings.ToLower(s) {
case "json", "":
return FormatJSON, true
case "ndjson":
return FormatNDJSON, true
case "table":
return FormatTable, true
case "csv":
return FormatCSV, true
default:
return FormatJSON, false
}
}
// String returns the string representation of a Format.
func (f Format) String() string {
switch f {
case FormatNDJSON:
return "ndjson"
case FormatTable:
return "table"
case FormatCSV:
return "csv"
default:
return "json"
}
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import "testing"
func TestParseFormat(t *testing.T) {
tests := []struct {
input string
want Format
wantOK bool
}{
{"json", FormatJSON, true},
{"JSON", FormatJSON, true},
{"Json", FormatJSON, true},
{"ndjson", FormatNDJSON, true},
{"NDJSON", FormatNDJSON, true},
{"Ndjson", FormatNDJSON, true},
{"table", FormatTable, true},
{"TABLE", FormatTable, true},
{"Table", FormatTable, true},
{"csv", FormatCSV, true},
{"CSV", FormatCSV, true},
{"Csv", FormatCSV, true},
{"", FormatJSON, true},
// Legacy/unknown values fall back to JSON with ok=false
{"data", FormatJSON, false},
{"raw", FormatJSON, false},
{"RAW", FormatJSON, false},
{"DATA", FormatJSON, false},
{"foobar", FormatJSON, false},
{"xml", FormatJSON, false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, ok := ParseFormat(tt.input)
if got != tt.want {
t.Errorf("ParseFormat(%q) format = %v, want %v", tt.input, got, tt.want)
}
if ok != tt.wantOK {
t.Errorf("ParseFormat(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
}
})
}
}
func TestFormatString(t *testing.T) {
tests := []struct {
format Format
want string
}{
{FormatJSON, "json"},
{FormatNDJSON, "ndjson"},
{FormatTable, "table"},
{FormatCSV, "csv"},
{Format(99), "json"}, // unknown falls back
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := tt.format.String()
if got != tt.want {
t.Errorf("Format(%d).String() = %q, want %q", tt.format, got, tt.want)
}
})
}
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"encoding/json"
"fmt"
"io"
"math/big"
"github.com/itchyny/gojq"
"github.com/larksuite/cli/errs"
)
// JqFilter applies a jq expression to data and writes the results to w.
// Scalar values are printed raw (no quotes for strings), matching jq -r behavior.
// Complex values (maps, arrays) are printed as indented JSON with Go's default
// HTML escaping (<, >, & → <, >, &).
func JqFilter(w io.Writer, data interface{}, expr string) error {
return jqFilter(w, data, expr, false)
}
// JqFilterRaw is like JqFilter but disables HTML escaping when re-marshaling
// complex jq results. Use it alongside OutRaw when the upstream envelope
// carries XML/HTML content that must survive --jq '.data.document' style
// projections without getting mangled into < escapes.
func JqFilterRaw(w io.Writer, data interface{}, expr string) error {
return jqFilter(w, data, expr, true)
}
func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error {
query, err := gojq.Parse(expr)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
}
code, err := gojq.Compile(query)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
}
// Normalize data through toGeneric so typed structs become map[string]any.
normalized := toGeneric(data)
// Convert json.Number values to gojq-compatible types.
normalized = convertNumbers(normalized)
iter := code.Run(normalized)
for {
v, ok := iter.Next()
if !ok {
break
}
if err, isErr := v.(error); isErr {
return errs.NewAPIError(errs.SubtypeUnknown, "jq error: %s", err).WithCause(err)
}
if err := writeJqValue(w, v, raw); err != nil {
return err
}
}
return nil
}
// ValidateJqFlags checks --jq flag compatibility with --output and --format flags,
// and validates the jq expression syntax. Returns nil if jqExpr is empty.
func ValidateJqFlags(jqExpr, outputFlag, format string) error {
if jqExpr == "" {
return nil
}
if outputFlag != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --output are mutually exclusive")
}
if format != "" && format != "json" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --format %s are mutually exclusive", format)
}
return ValidateJqExpression(jqExpr)
}
// ValidateJqExpression checks whether a jq expression is syntactically valid.
func ValidateJqExpression(expr string) error {
query, err := gojq.Parse(expr)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
}
_, err = gojq.Compile(query)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
}
return nil
}
// writeJqValue writes a single jq result value to w.
// Scalars are printed raw; complex values as indented JSON.
// When raw is true, HTML escaping is disabled on complex values so that
// embedded XML/HTML content is preserved as-is.
func writeJqValue(w io.Writer, v interface{}, raw bool) error {
switch val := v.(type) {
case nil:
fmt.Fprintln(w, "null")
case bool:
fmt.Fprintln(w, val)
case int:
fmt.Fprintln(w, val)
case float64:
// Use %g to avoid trailing zeros, matching jq behavior.
fmt.Fprintf(w, "%g\n", val)
case *big.Int:
fmt.Fprintln(w, val.String())
case string:
// Raw output for strings (no quotes), matching jq -r.
fmt.Fprintln(w, val)
default:
// Complex value (map, array): indented JSON.
if raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to marshal jq result: %s", err).WithCause(err)
}
return nil
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to marshal jq result: %s", err).WithCause(err)
}
fmt.Fprintln(w, string(b))
}
return nil
}
// convertNumbers recursively converts json.Number values to int or float64
// so that gojq can process them correctly.
func convertNumbers(v interface{}) interface{} {
switch val := v.(type) {
case json.Number:
if i, err := val.Int64(); err == nil {
return int(i)
}
if f, err := val.Float64(); err == nil {
return f
}
// Fallback: return as string (shouldn't happen for valid JSON numbers).
return val.String()
case map[string]interface{}:
for k, elem := range val {
val[k] = convertNumbers(elem)
}
return val
case []interface{}:
for i, elem := range val {
val[i] = convertNumbers(elem)
}
return val
default:
return v
}
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
func TestJqFilterRaw_PreservesXMLInComplexValue(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
"document": map[string]interface{}{
"title": "<title>hello & welcome</title>",
"content": "<p>a < b & c > d</p>",
},
},
}
var raw bytes.Buffer
if err := JqFilterRaw(&raw, data, ".data.document"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Raw path must keep <, >, & as literal characters, not Go json-encoder's
// default < / > / & unicode escapes.
for _, unicodeEsc := range []string{"\\u003c", "\\u003e", "\\u0026"} {
if strings.Contains(raw.String(), unicodeEsc) {
t.Errorf("JqFilterRaw unexpectedly HTML-escaped %s: %s", unicodeEsc, raw.String())
}
}
if !strings.Contains(raw.String(), "<title>") {
t.Errorf("JqFilterRaw dropped raw <title>: %s", raw.String())
}
var escaped bytes.Buffer
if err := JqFilter(&escaped, data, ".data.document"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// JqFilter keeps Go's default HTML escaping for back-compat.
if !strings.Contains(escaped.String(), "\\u003c") {
t.Errorf("JqFilter should HTML-escape < for back-compat: %s", escaped.String())
}
}
func TestJqFilterRaw_ScalarMatchesJqFilter(t *testing.T) {
data := map[string]interface{}{"content": "<title>hello</title>"}
var raw, plain bytes.Buffer
if err := JqFilterRaw(&raw, data, ".content"); err != nil {
t.Fatalf("raw: %v", err)
}
if err := JqFilter(&plain, data, ".content"); err != nil {
t.Fatalf("plain: %v", err)
}
// Scalar string path is raw in both (matches jq -r), so output is identical.
if raw.String() != plain.String() {
t.Errorf("scalar output diverged: raw=%q plain=%q", raw.String(), plain.String())
}
if !strings.Contains(raw.String(), "<title>") {
t.Errorf("scalar output dropped <title>: %q", raw.String())
}
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
func TestJqFilter(t *testing.T) {
data := map[string]interface{}{
"ok": true,
"identity": "user",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice", "age": 30},
map[string]interface{}{"name": "Bob", "age": 25},
map[string]interface{}{"name": "Charlie", "age": 35},
},
"total": 3,
},
"meta": map[string]interface{}{
"count": 3,
},
}
tests := []struct {
name string
expr string
want string
wantErr bool
}{
{
name: "identity expression",
expr: ".",
want: `"ok"`,
},
{
name: "field access .ok",
expr: ".ok",
want: "true\n",
},
{
name: "string field raw output",
expr: ".identity",
want: "user\n",
},
{
name: "nested field access",
expr: ".data.total",
want: "3\n",
},
{
name: "meta count",
expr: ".meta.count",
want: "3\n",
},
{
name: "array iteration",
expr: ".data.items[].name",
want: "Alice\nBob\nCharlie\n",
},
{
name: "pipe and select",
expr: `.data.items[] | select(.age > 28) | .name`,
want: "Alice\nCharlie\n",
},
{
name: "length builtin",
expr: ".data.items | length",
want: "3\n",
},
{
name: "keys builtin",
expr: ".data | keys",
want: "[\n \"items\",\n \"total\"\n]\n",
},
{
name: "null for missing field",
expr: ".nonexistent",
want: "null\n",
},
{
name: "complex value output",
expr: ".data.items[0]",
want: "{\n \"age\": 30,\n \"name\": \"Alice\"\n}\n",
},
{
name: "invalid expression",
expr: "invalid[",
wantErr: true,
},
{
name: "multiple outputs",
expr: ".ok, .identity",
want: "true\nuser\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
err := JqFilter(&buf, data, tt.expr)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.name == "identity expression" {
// For identity, just verify it contains the key fields
if !strings.Contains(buf.String(), `"ok"`) {
t.Errorf("identity output missing 'ok' key")
}
return
}
if buf.String() != tt.want {
t.Errorf("got %q, want %q", buf.String(), tt.want)
}
})
}
}
func TestJqFilter_WithStruct(t *testing.T) {
// Test that toGeneric normalizes structs properly
type inner struct {
Name string `json:"name"`
}
data := struct {
OK bool `json:"ok"`
Item *inner `json:"item"`
}{
OK: true,
Item: &inner{Name: "test"},
}
var buf bytes.Buffer
err := JqFilter(&buf, data, ".item.name")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "test" {
t.Errorf("got %q, want %q", got, "test")
}
}
func TestValidateJqFlags(t *testing.T) {
tests := []struct {
name string
jqExpr string
outputFlag string
format string
wantErr string
}{
{name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""},
{name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""},
{name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""},
{name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"},
{name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"},
{name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"},
{name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateJqFlags(tt.jqExpr, tt.outputFlag, tt.format)
if tt.wantErr == "" {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.wantErr)
return
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr)
}
})
}
}
func TestValidateJqExpression(t *testing.T) {
tests := []struct {
expr string
wantErr bool
}{
{".", false},
{".data", false},
{".data.items[].name", false},
{`.data.items[] | select(.name == "Alice")`, false},
{"length", false},
{"keys", false},
{"invalid[", true},
{".foo | invalid_func", true},
}
for _, tt := range tests {
t.Run(tt.expr, func(t *testing.T) {
err := ValidateJqExpression(tt.expr)
if tt.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
// Lark API generic error code constants.
// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
//
// Kept as exported identifiers because external shortcut packages reference
// them by name (e.g. LarkErrOwnershipMismatch). The canonical Category /
// Subtype / Retryable metadata for each code lives in internal/errclass and
// must remain the single source of truth.
const (
// Auth: token missing / invalid / expired.
LarkErrTokenMissing = 99991661 // Authorization header missing or empty
LarkErrTokenBadFmt = 99991671 // token format error (must start with "t-" or "u-")
LarkErrTokenInvalid = 99991668 // user_access_token invalid or expired
LarkErrATInvalid = 99991663 // access_token invalid (generic)
LarkErrTokenExpired = 99991677 // user_access_token expired, refresh to obtain a new one
// Permission: scope not granted.
LarkErrAppScopeNotEnabled = 99991672 // app has not applied for the required API scope
LarkErrTokenNoPermission = 99991676 // token lacks the required scope
LarkErrUserScopeInsufficient = 99991679 // user has not granted the required scope
LarkErrUserNotAuthorized = 230027 // user not authorized
// App credential / status.
LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect (Open API)
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
// "Wrong app credentials" code from the LEGACY TAT endpoint
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
// are retained as a defensive fallback should 10014 still arrive.
LarkErrTATInvalidSecret = 10014
// Rate limit.
LarkErrRateLimit = 99991400 // request frequency limit exceeded
// Refresh token errors (authn service).
LarkErrRefreshInvalid = 20026 // refresh_token invalid or v1 format
LarkErrRefreshExpired = 20037 // refresh_token expired
LarkErrRefreshRevoked = 20064 // refresh_token revoked
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
// Drive shortcut / cross-space constraints.
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
// Wiki write-path lock contention (e.g. concurrent wiki +node-create under the
// same parent). Server-side write lock; transient, safe to retry with backoff.
LarkErrWikiLockContention = 131009
// Sheets float image: width/height/offset out of range or invalid.
LarkErrSheetsFloatImageInvalidDims = 1310246
// Drive permission apply: per-user-per-document submission limit (5/day) reached.
LarkErrDrivePermApplyRateLimit = 1063006
// Drive permission apply: request is not applicable for this document
// (e.g. the document is configured to disallow access requests, or the
// caller already holds the requested permission, or the target type does
// not accept apply operations).
LarkErrDrivePermApplyNotApplicable = 1063007
// IM resource ownership mismatch.
LarkErrOwnershipMismatch = 231205
// Mail send: account / mailbox-level failures returned by
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
// because the typed envelope preserves Problem.Code exactly as returned by
// the server.
// These codes indicate the entire batch will keep failing identically and
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
LarkErrMailQuota = 1236010 // mail quota limit
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
)
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"testing"
)
func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
got int
want int
}{
{name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013},
{name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007},
{name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008},
{name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009},
{name: "mail quota", got: LarkErrMailQuota, want: 1236010},
{name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.got != tt.want {
t.Fatalf("code=%d, want %d", tt.got, tt.want)
}
})
}
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"encoding/json"
"fmt"
"io"
"os"
"github.com/larksuite/cli/internal/validate"
)
// PrintJson prints data as formatted JSON to w.
func PrintJson(w io.Writer, data interface{}) {
injectNotice(data)
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
return
}
fmt.Fprintln(w, string(b))
}
// injectNotice adds a "_notice" field into CLI envelope maps.
// Only modifies map[string]interface{} values that have an "ok" key
// (e.g. doctor, auth, config commands that build map envelopes directly).
//
// Struct-based envelopes (Envelope, the typed error envelope) are NOT handled
// here — callers must set the Notice field explicitly via GetNotice().
// See: shortcuts/common/runner.go Out(), output/errors.go WriteTypedErrorEnvelope().
func injectNotice(data interface{}) {
if PendingNotice == nil {
return
}
m, ok := data.(map[string]interface{})
if !ok {
return
}
if _, isEnvelope := m["ok"]; !isEnvelope {
return
}
notice := PendingNotice()
if notice == nil {
return
}
m["_notice"] = notice
}
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
func PrintNdjson(w io.Writer, data interface{}) {
emit := func(item interface{}) {
b, err := json.Marshal(item)
if err != nil {
fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
return
}
fmt.Fprintln(w, string(b))
}
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
emit(item)
}
} else {
emit(data)
}
}
func cellStr(val interface{}) string {
if val == nil {
return ""
}
var s string
switch v := val.(type) {
case string:
s = v
case json.Number:
s = v.String()
case float64:
if v == float64(int(v)) {
s = fmt.Sprintf("%d", int(v))
} else {
s = fmt.Sprintf("%g", v)
}
case bool:
s = fmt.Sprintf("%v", v)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
s = string(b)
}
// Sanitize for terminal display: strip ANSI escapes, control chars, dangerous Unicode.
return validate.SanitizeForTerminal(s)
}
// PrintTable prints rows as a table to w.
// Delegates to FormatAsTable for flattening, column union, and width handling.
func PrintTable(w io.Writer, rows []map[string]interface{}) {
if len(rows) == 0 {
fmt.Fprintln(w, "(no data)")
return
}
items := make([]interface{}, len(rows))
for i, r := range rows {
items[i] = r
}
FormatAsTable(w, items)
}
// PrintSuccess prints a success message to w.
func PrintSuccess(w io.Writer, msg string) {
fmt.Fprintf(w, "OK: %s\n", msg)
}
// PrintError prints an error message to w.
func PrintError(w io.Writer, msg string) {
fmt.Fprintf(w, "ERROR: %s\n", msg)
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"encoding/json"
"testing"
)
func TestPrintJson_InjectNotice_Map(t *testing.T) {
origNotice := PendingNotice
PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"update": "available"}
}
defer func() { PendingNotice = origNotice }()
data := map[string]interface{}{"ok": true, "data": "test"}
var buf bytes.Buffer
PrintJson(&buf, data)
var got map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("failed to parse: %v", err)
}
notice, ok := got["_notice"].(map[string]interface{})
if !ok {
t.Fatal("expected _notice in map-based envelope")
}
if notice["update"] != "available" {
t.Errorf("expected update=available, got %v", notice["update"])
}
}
func TestPrintJson_InjectNotice_SkipsNonEnvelope(t *testing.T) {
origNotice := PendingNotice
PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"update": "available"}
}
defer func() { PendingNotice = origNotice }()
// Map without "ok" key should not get _notice
data := map[string]interface{}{"name": "test"}
var buf bytes.Buffer
PrintJson(&buf, data)
var got map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("failed to parse: %v", err)
}
if _, ok := got["_notice"]; ok {
t.Error("expected no _notice for non-envelope map")
}
}
func TestPrintJson_Struct_PreservesNotice(t *testing.T) {
origNotice := PendingNotice
PendingNotice = nil // no global notice
defer func() { PendingNotice = origNotice }()
// Struct with Notice already set should preserve it
env := &Envelope{
OK: true,
Identity: "user",
Data: "hello",
Notice: map[string]interface{}{"update": "set-by-caller"},
}
var buf bytes.Buffer
PrintJson(&buf, env)
var got map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("failed to parse: %v", err)
}
notice, ok := got["_notice"].(map[string]interface{})
if !ok {
t.Fatal("expected _notice from struct field")
}
if notice["update"] != "set-by-caller" {
t.Errorf("expected update=set-by-caller, got %v", notice["update"])
}
}
func TestPrintJson_NoNotice(t *testing.T) {
origNotice := PendingNotice
PendingNotice = nil
defer func() { PendingNotice = origNotice }()
data := map[string]interface{}{"ok": true, "data": "test"}
var buf bytes.Buffer
PrintJson(&buf, data)
var got map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("failed to parse: %v", err)
}
if _, ok := got["_notice"]; ok {
t.Error("expected no _notice when PendingNotice is nil")
}
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"io"
"sync"
"time"
)
// spinnerFrames are braille spinner glyphs cycled to animate progress.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
spinnerInterval = 80 * time.Millisecond
spinnerHideCursor = "\x1b[?25l"
spinnerShowCursor = "\x1b[?25h"
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
)
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
// until the returned stop() is called, e.g.:
//
// ⠹ Publishing dev → main... 3s
//
// It is meant for slow operations (long polls, first-time provisioning) so the
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
// animation never pollutes stdout — the JSON/pretty result stays clean.
//
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
//
// stop() clears the spinner line, restores the cursor, and blocks until the
// render goroutine has finished — so callers can safely write the result to
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
// it is safe to call more than once (e.g. an explicit call plus a defer).
func StartSpinner(w io.Writer, enabled bool, label string) func() {
if !enabled || w == nil {
return func() {}
}
done := make(chan struct{})
finished := make(chan struct{})
start := time.Now()
go func() {
defer close(finished)
frame := 0
fmt.Fprint(w, spinnerHideCursor)
render := func() {
elapsed := int(time.Since(start).Seconds())
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
frame = (frame + 1) % len(spinnerFrames)
}
render()
ticker := time.NewTicker(spinnerInterval)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
return
case <-ticker.C:
render()
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
close(done)
<-finished // wait for the line to be cleared before returning
})
}
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, false, "working")
stop()
stop() // idempotent
if buf.Len() != 0 {
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
}
}
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
stop := StartSpinner(nil, true, "working")
stop() // must not panic
}
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, true, "Publishing")
// The goroutine renders the first frame synchronously before selecting on
// the stop channel, so even an immediate stop() yields one full cycle.
stop()
stop() // idempotent, must not panic or double-write after finished
out := buf.String()
if !strings.Contains(out, spinnerHideCursor) {
t.Errorf("missing hide-cursor escape:\n%q", out)
}
if !strings.Contains(out, spinnerFrames[0]) {
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
}
if !strings.Contains(out, "Publishing...") {
t.Errorf("missing label:\n%q", out)
}
if !strings.Contains(out, spinnerClearLine) {
t.Errorf("missing clear-line escape:\n%q", out)
}
if !strings.HasSuffix(out, spinnerShowCursor) {
t.Errorf("must end by restoring the cursor:\n%q", out)
}
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"io"
"strings"
)
const maxColWidth = 100
// FormatAsTable formats data as a table and writes it to w.
// - []interface{} (array of objects) → header + separator + rows
// - map[string]interface{} (single object) → key-value two-column table
// - empty array → "(empty)"
func FormatAsTable(w io.Writer, data interface{}) {
FormatAsTablePaginated(w, data, true)
}
// FormatAsTablePaginated formats data as a table with pagination awareness.
// When isFirstPage is true, outputs the header; otherwise only data rows.
func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
fmt.Fprintln(w, "(empty)")
} else {
// Not a list and not an object — print as JSON fallback
PrintJson(w, data)
}
return
}
if len(rows) == 0 {
if isFirstPage {
fmt.Fprintln(w, "(empty)")
}
return
}
if !isList {
// Single object: key-value two-column format
formatKeyValueTable(w, rows[0], cols)
return
}
// Calculate column widths (clamped to maxColWidth)
widths := computeColumnWidths(rows, cols)
if isFirstPage {
writeHeader(w, cols, widths)
}
for _, row := range rows {
writeRow(w, row, cols, widths)
}
}
// formatKeyValueTable renders a single object as a two-column key-value table.
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
maxKeyWidth := 0
for _, col := range cols {
kw := stringWidth(col)
if kw > maxKeyWidth {
maxKeyWidth = kw
}
}
for _, col := range cols {
val := row[col]
val = truncateToWidth(val, maxColWidth)
fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
}
}
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
func computeColumnWidths(rows []map[string]string, cols []string) []int {
widths := make([]int, len(cols))
for i, col := range cols {
widths[i] = stringWidth(col)
}
for _, row := range rows {
for i, col := range cols {
cw := stringWidth(row[col])
if cw > widths[i] {
widths[i] = cw
}
}
}
// Clamp to max
for i := range widths {
if widths[i] > maxColWidth {
widths[i] = maxColWidth
}
}
return widths
}
// writeHeader writes the header row and separator line.
func writeHeader(w io.Writer, cols []string, widths []int) {
var header []string
var sep []string
for i, col := range cols {
header = append(header, padToWidth(col, widths[i]))
sep = append(sep, strings.Repeat("─", widths[i]))
}
fmt.Fprintln(w, strings.Join(header, " "))
fmt.Fprintln(w, strings.Join(sep, " "))
}
// writeRow writes a single data row.
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
var cells []string
for i, col := range cols {
val := truncateToWidth(row[col], widths[i])
cells = append(cells, padToWidth(val, widths[i]))
}
fmt.Fprintln(w, strings.Join(cells, " "))
}
// padToWidth pads a string with spaces to reach the target display width.
func padToWidth(s string, targetWidth int) string {
sw := stringWidth(s)
if sw >= targetWidth {
return s
}
return s + strings.Repeat(" ", targetWidth-sw)
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
func TestFormatAsTable_ObjectArray(t *testing.T) {
data := []interface{}{
map[string]interface{}{"name": "Alice", "age": float64(30)},
map[string]interface{}{"name": "Bob", "age": float64(25)},
}
var buf bytes.Buffer
FormatAsTable(&buf, data)
out := buf.String()
if !strings.Contains(out, "name") {
t.Errorf("output should contain 'name' header, got:\n%s", out)
}
if !strings.Contains(out, "age") {
t.Errorf("output should contain 'age' header, got:\n%s", out)
}
if !strings.Contains(out, "Alice") {
t.Errorf("output should contain 'Alice', got:\n%s", out)
}
if !strings.Contains(out, "Bob") {
t.Errorf("output should contain 'Bob', got:\n%s", out)
}
// Should contain separator with ─
if !strings.Contains(out, "─") {
t.Errorf("output should contain ─ separator, got:\n%s", out)
}
}
func TestFormatAsTable_SingleObject(t *testing.T) {
data := map[string]interface{}{
"name": "Alice",
"age": float64(30),
}
var buf bytes.Buffer
FormatAsTable(&buf, data)
out := buf.String()
if !strings.Contains(out, "name") {
t.Errorf("output should contain 'name', got:\n%s", out)
}
if !strings.Contains(out, "Alice") {
t.Errorf("output should contain 'Alice', got:\n%s", out)
}
}
func TestFormatAsTable_EmptyArray(t *testing.T) {
data := []interface{}{}
var buf bytes.Buffer
FormatAsTable(&buf, data)
out := strings.TrimSpace(buf.String())
if out != "(empty)" {
t.Errorf("empty array should output '(empty)', got:\n%s", out)
}
}
func TestFormatAsTable_NestedFlattening(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"user": map[string]interface{}{
"name": "Alice",
},
"id": float64(1),
},
}
var buf bytes.Buffer
FormatAsTable(&buf, data)
out := buf.String()
if !strings.Contains(out, "user.name") {
t.Errorf("output should contain flattened 'user.name' column, got:\n%s", out)
}
if !strings.Contains(out, "Alice") {
t.Errorf("output should contain 'Alice', got:\n%s", out)
}
}
func TestFormatAsTable_ColumnUnionFromAllRows(t *testing.T) {
data := []interface{}{
map[string]interface{}{"a": "1"},
map[string]interface{}{"a": "2", "b": "3"},
}
var buf bytes.Buffer
FormatAsTable(&buf, data)
out := buf.String()
if !strings.Contains(out, "b") {
t.Errorf("output should contain column 'b' from second row, got:\n%s", out)
}
}
func TestFormatAsTablePaginated_FirstPage(t *testing.T) {
data := []interface{}{
map[string]interface{}{"name": "Alice"},
}
var buf bytes.Buffer
FormatAsTablePaginated(&buf, data, true)
out := buf.String()
// First page should have header
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) < 3 {
t.Errorf("first page should have header + separator + data, got %d lines:\n%s", len(lines), out)
}
}
func TestFormatAsTablePaginated_ContinuationPage(t *testing.T) {
data := []interface{}{
map[string]interface{}{"name": "Bob"},
}
var buf bytes.Buffer
FormatAsTablePaginated(&buf, data, false)
out := buf.String()
// Continuation page should not have header/separator
if strings.Contains(out, "─") {
t.Errorf("continuation page should not contain separator, got:\n%s", out)
}
if !strings.Contains(out, "Bob") {
t.Errorf("continuation page should contain data, got:\n%s", out)
}
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 1 {
t.Errorf("continuation page should have 1 data line, got %d lines:\n%s", len(lines), out)
}
}
func TestFormatAsTable_ColumnWidthClamp(t *testing.T) {
// Create a value longer than maxColWidth
longVal := strings.Repeat("x", 101)
data := []interface{}{
map[string]interface{}{"col": longVal},
}
var buf bytes.Buffer
FormatAsTable(&buf, data)
out := buf.String()
if strings.Contains(out, longVal) {
t.Errorf("output should not contain the full long value (should be truncated)")
}
if !strings.Contains(out, "…") {
t.Errorf("output should contain truncation marker …, got:\n%s", out)
}
}