mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
codex/base
...
feat/outpu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
041bf48e0e |
@@ -40,6 +40,7 @@ type APIOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JSON bool
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string
|
||||
@@ -88,6 +89,11 @@ Examples:
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.As = core.Identity(asStr)
|
||||
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Format = format
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -103,8 +109,8 @@ Examples:
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
@@ -116,7 +122,7 @@ Examples:
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
@@ -263,10 +269,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
format, formatOK := output.ParseFormat(opts.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
format, _ := output.ParseFormat(opts.Format)
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
@@ -343,6 +346,24 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatPretty:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatPretty)
|
||||
return errs.MarkRaw(apiErr)
|
||||
}
|
||||
scanResult := output.ScanForSafety(commandPath, result, errOut)
|
||||
if scanResult.Blocked {
|
||||
return errs.MarkRaw(scanResult.BlockErr)
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
output.FormatValue(out, result, output.FormatPretty)
|
||||
return nil
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
|
||||
@@ -68,6 +68,42 @@ func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_OutputFormatResolution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "json shorthand", args: []string{"--json"}, want: "json"},
|
||||
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
|
||||
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
args := []string{"GET", "/open-apis/test", "--as", "bot"}
|
||||
cmd.SetArgs(append(args, tt.args...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected options to be captured")
|
||||
}
|
||||
if gotOpts.Format != tt.want {
|
||||
t.Fatalf("format = %q, want %q", gotOpts.Format, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRun(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -169,6 +205,43 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PrettyFormatsRealResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("pretty output data = %#v", got["data"])
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("pretty output data.items = %#v", data["items"])
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
|
||||
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -571,6 +644,46 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("page-all pretty output data = %#v", got["data"])
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
|
||||
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
type apiContentSafetyProvider struct {
|
||||
called bool
|
||||
path string
|
||||
|
||||
@@ -355,7 +355,31 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
func TestAuthScopesCmd_JSONShorthand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected options to be captured")
|
||||
}
|
||||
if !gotOpts.JSON || gotOpts.Format != "json" {
|
||||
t.Fatalf("JSON = %v, format = %q; want true, json", gotOpts.JSON, gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_ExplicitFormatWinsOverJSONShorthand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -376,8 +400,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
if gotOpts.Format != "pretty" {
|
||||
t.Errorf("expected explicit format pretty, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,11 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
format, err := output.JSONPrettyFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Format = format
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -41,8 +43,11 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", output.JSONPrettyFormats.Usage())
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return output.JSONPrettyFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -75,10 +80,10 @@ func authScopesRun(opts *ScopesOptions) error {
|
||||
"failed to get app scope info: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
|
||||
fmt.Fprintf(f.IOStreams.Out, "App ID: %s\n", config.AppID)
|
||||
fmt.Fprintf(f.IOStreams.Out, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
|
||||
for _, s := range appInfo.UserScopes {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s)
|
||||
fmt.Fprintf(f.IOStreams.Out, " • %s\n", s)
|
||||
}
|
||||
} else {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -26,6 +28,35 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) {
|
||||
t.Cleanup(func() { getAppInfoFn = prev })
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_PrettyWritesBulletedScopesToStdout(t *testing.T) {
|
||||
prev := getAppInfoFn
|
||||
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
return &appInfo{UserScopes: []string{"im:message"}}, nil
|
||||
}
|
||||
t.Cleanup(func() { getAppInfoFn = prev })
|
||||
|
||||
opts := scopesTestFactory(t)
|
||||
opts.Format = "pretty"
|
||||
out, ok := opts.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stdout type = %T, want *bytes.Buffer", opts.Factory.IOStreams.Out)
|
||||
}
|
||||
errOut, ok := opts.Factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stderr type = %T, want *bytes.Buffer", opts.Factory.IOStreams.ErrOut)
|
||||
}
|
||||
|
||||
if err := authScopesRun(opts); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), " • im:message\n") {
|
||||
t.Fatalf("stdout missing bulleted scope: %q", out.String())
|
||||
}
|
||||
if strings.Contains(errOut.String(), "im:message") {
|
||||
t.Fatalf("scope should remain on stdout, stderr = %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
|
||||
// authScopesRun. Config has a non-empty AppID so we get past the config gate
|
||||
// and reach the getAppInfoFn call.
|
||||
|
||||
@@ -234,6 +234,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
installCobraValidationGuards(rootCmd)
|
||||
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -26,6 +30,85 @@ func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildValidationTestRoot(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
return Build(context.Background(), cmdutil.InvocationContext{},
|
||||
WithIO(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}),
|
||||
WithoutPlugins(),
|
||||
WithoutServiceCommands(),
|
||||
WithoutStrictMode(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestBuiltRoot_TopLevelTypoReturnsStructuredSuggestion(t *testing.T) {
|
||||
root := buildValidationTestRoot(t)
|
||||
root.SetArgs([]string{"imm"})
|
||||
|
||||
err := root.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "imm" {
|
||||
t.Fatalf("params = %v, want one entry named imm", validationErr.Params)
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range validationErr.Params[0].Suggestions {
|
||||
if candidate == "im" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("suggestions = %v, want im", validationErr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltRoot_SheetsOneRequiredGroupReturnsValidationExit(t *testing.T) {
|
||||
root := buildValidationTestRoot(t)
|
||||
root.SetArgs([]string{
|
||||
"sheets", "+csv-put",
|
||||
"--url", "https://example.com/sheets/token",
|
||||
"--sheet-name", "Sheet1",
|
||||
"--csv", "a,b",
|
||||
})
|
||||
|
||||
err := root.Execute()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltRoot_MailUnknownFlagUsesSharedSuggestions(t *testing.T) {
|
||||
root := buildValidationTestRoot(t)
|
||||
root.SetArgs([]string{"mail", "+send", "--tos", "alice@example.com"})
|
||||
|
||||
err := root.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "--tos" {
|
||||
t.Fatalf("params = %v, want one entry named --tos", validationErr.Params)
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range validationErr.Params[0].Suggestions {
|
||||
if candidate == "--to" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("suggestions = %v, want --to", validationErr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func findCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
parts := strings.Fields(path)
|
||||
cmd := root
|
||||
|
||||
@@ -9,28 +9,18 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --query", "query", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagName(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
func parseFlagError(t *testing.T, c *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
err := c.Flags().Parse(args)
|
||||
if err == nil {
|
||||
t.Fatalf("Parse(%v) returned nil", args)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
@@ -39,7 +29,7 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c.Flags().String("find", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--rang")) // typo of --range
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
@@ -82,23 +72,86 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
c.Flags().String("find", "", "")
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--find"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
|
||||
// structured param, generic --help hint (no "did you mean" suggestion).
|
||||
// Non-unknown-flag errors retain the same validation shape and identify the
|
||||
// flag from pflag's typed ValueRequiredError.
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--find" {
|
||||
t.Errorf("Params=%v, want one entry named --find", verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_SheetsListsVisibleFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
sheets := &cobra.Command{Use: "sheets"}
|
||||
cmdmeta.SetDomain(sheets, "sheets")
|
||||
root.AddCommand(sheets)
|
||||
sheets.Flags().String("range", "", "")
|
||||
sheets.Flags().Int("width", 0, "")
|
||||
|
||||
err := flagDidYouMean(sheets, parseFlagError(t, sheets, "--cols"))
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
for _, want := range []string{"--range", "--width"} {
|
||||
if !strings.Contains(validationErr.Hint, want) {
|
||||
t.Errorf("hint should include %q, got %q", want, validationErr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_InvalidValueTypedError(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().Int("width", 0, "")
|
||||
|
||||
// A non-numeric value for a typed flag surfaces pflag's InvalidValueError.
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--width=abc"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--width" || verr.Params[0].Reason != "invalid flag value" {
|
||||
t.Errorf("Params = %v, want one --width entry with reason 'invalid flag value'", verr.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_InvalidSyntaxTypedError(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
|
||||
// An empty flag name is bad flag syntax and surfaces pflag's InvalidSyntaxError.
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--=oops"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Reason != "invalid flag syntax" {
|
||||
t.Errorf("Params = %v, want one entry with reason 'invalid flag syntax'", verr.Params)
|
||||
}
|
||||
}
|
||||
|
||||
296
cmd/root.go
296
cmd/root.go
@@ -241,10 +241,10 @@ func configureFlagCompletions(args []string) {
|
||||
// dispatcher no longer promotes any legacy shape here.
|
||||
// 2. PartialFailure / BareError signals: the result envelope is already on
|
||||
// stdout; honor the exit code and write nothing to stderr.
|
||||
// 3. Residual cobra usage errors (missing required flag, unknown command,
|
||||
// argument validation): typed as an invalid_argument envelope (exit 2),
|
||||
// matching the explicit flag/subcommand guards. Flag parse errors are
|
||||
// already typed upstream by the root FlagErrorFunc.
|
||||
// 3. Any untyped error that reaches this boundary is an internal fault.
|
||||
// Cobra argument, required-flag and flag-group errors are typed at their
|
||||
// execution stages by installCobraValidationGuards; flag parse errors are
|
||||
// typed by the root FlagErrorFunc.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
@@ -283,57 +283,14 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return bareErr.Code
|
||||
}
|
||||
|
||||
// Errors reaching here are untyped: every RunE returns a typed errs.* error
|
||||
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
|
||||
// is either a cobra usage mistake (missing required flag, unknown command,
|
||||
// wrong arg count), which cobra surfaces as a plain error identified by its
|
||||
// stable text — the same external contract unknownFlagName relies on — or an
|
||||
// untyped error that leaked past the typed boundary. Classify the former as
|
||||
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
|
||||
// internal fault (exit 5) rather than blaming the user's input. The message
|
||||
// is preserved either way, and the typed envelope still carries any pending
|
||||
// deprecation notice.
|
||||
var fallback error
|
||||
if isCobraUsageError(err) {
|
||||
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
|
||||
} else {
|
||||
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
// Every user-input stage is typed before execution. A bare error here has
|
||||
// crossed that boundary unexpectedly and must remain visible as an internal
|
||||
// fault instead of being guessed from English message fragments.
|
||||
fallback := errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
|
||||
return output.ExitCodeOf(fallback)
|
||||
}
|
||||
|
||||
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
|
||||
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
|
||||
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
|
||||
// not a typed value we can match on, so the dispatcher recognizes them by text;
|
||||
// this is the same external contract unknownFlagName already depends on. A
|
||||
// residual error matching none of these has leaked the typed boundary and is
|
||||
// treated as an internal fault, not a user error.
|
||||
var cobraUsageErrorMarkers = []string{
|
||||
"unknown command ",
|
||||
"unknown flag: ",
|
||||
"unknown shorthand",
|
||||
"required flag(s) ",
|
||||
"flag needs an argument",
|
||||
"bad flag syntax:",
|
||||
"no such flag ",
|
||||
"invalid argument ",
|
||||
"arg(s), ", // accepts / requires N arg(s), received / only received M
|
||||
}
|
||||
|
||||
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
|
||||
// identified by the stable error text of the pinned cobra version.
|
||||
func isCobraUsageError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, m := range cobraUsageErrorMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
// group commands (no Run/RunE) with an unknown_subcommand error.
|
||||
//
|
||||
@@ -345,6 +302,10 @@ func isCobraUsageError(err error) bool {
|
||||
// with reason_code=risk_not_annotated.
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
// Cobra's legacy Args fallback rejects an unknown top-level token before
|
||||
// RunE can produce ranked suggestions. Explicitly accepting positional
|
||||
// tokens lets every pure group, including root, reach the shared guard.
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
// Route an unknown subcommand to unknownSubcommandRunE even when flags
|
||||
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
|
||||
@@ -362,6 +323,132 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// installCobraValidationGuards types errors at the stage where Cobra knows
|
||||
// they are user input: positional argument validation, required flags and flag
|
||||
// groups. This removes the need for final-boundary message matching.
|
||||
func installCobraValidationGuards(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if validateArgs := cmd.Args; validateArgs != nil {
|
||||
cmd.Args = func(c *cobra.Command, args []string) error {
|
||||
err := validateArgs(c, args)
|
||||
if err == nil || errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
previousPreRunE := cmd.PreRunE
|
||||
previousPreRun := cmd.PreRun
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
if previousPreRunE != nil {
|
||||
if err := previousPreRunE(c, args); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if previousPreRun != nil {
|
||||
previousPreRun(c, args)
|
||||
}
|
||||
if err := c.ValidateRequiredFlags(); err != nil {
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
c.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||
if flag.Changed || len(flag.Annotations[cobra.BashCompOneRequiredFlag]) == 0 {
|
||||
return
|
||||
}
|
||||
validationErr.WithParams(errs.InvalidParam{Name: "--" + flag.Name, Reason: "required flag is missing"})
|
||||
})
|
||||
return validationErr.WithHint("run `%s --help` to see required flags", c.CommandPath())
|
||||
}
|
||||
if err := c.ValidateFlagGroups(); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).
|
||||
WithParams(invalidFlagGroupParams(err.Error())...).
|
||||
WithHint("run `%s --help` to see valid flag combinations", c.CommandPath()).
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.PreRun = nil
|
||||
|
||||
for _, child := range cmd.Commands() {
|
||||
installCobraValidationGuards(child)
|
||||
}
|
||||
}
|
||||
|
||||
type flagGroupConstraint int
|
||||
|
||||
const (
|
||||
flagGroupRequiredTogether flagGroupConstraint = iota
|
||||
flagGroupOneRequired
|
||||
flagGroupMutuallyExclusive
|
||||
)
|
||||
|
||||
// invalidFlagGroupParams extracts the offending flag group from Cobra's
|
||||
// flag-group validation error. The message always names the group as
|
||||
// "[flag-a flag-b ...]" and its wording identifies the constraint, so both are
|
||||
// read straight from the message instead of re-deriving Cobra's private group
|
||||
// state. Pinned to cobra v1.10.2's message format (see go.mod).
|
||||
func invalidFlagGroupParams(message string) []errs.InvalidParam {
|
||||
names := flagGroupNamesFromMessage(message)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
return buildFlagGroupParams(names, flagGroupConstraintFromMessage(message))
|
||||
}
|
||||
|
||||
func buildFlagGroupParams(names []string, constraint flagGroupConstraint) []errs.InvalidParam {
|
||||
flagNames := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.TrimLeft(name, "-")
|
||||
if name != "" {
|
||||
flagNames = append(flagNames, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(flagNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
group := "[" + strings.Join(flagNames, " ") + "]"
|
||||
reason := "invalid flag combination in " + group
|
||||
switch constraint {
|
||||
case flagGroupRequiredTogether:
|
||||
reason = "all of " + group + " required together"
|
||||
case flagGroupOneRequired:
|
||||
reason = "one of " + group + " required"
|
||||
case flagGroupMutuallyExclusive:
|
||||
reason = "only one of " + group + " allowed"
|
||||
}
|
||||
params := make([]errs.InvalidParam, 0, len(flagNames))
|
||||
for _, name := range flagNames {
|
||||
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func flagGroupNamesFromMessage(message string) []string {
|
||||
start := strings.IndexByte(message, '[')
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
end := strings.IndexByte(message[start+1:], ']')
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(message[start+1 : start+1+end])
|
||||
}
|
||||
|
||||
func flagGroupConstraintFromMessage(message string) flagGroupConstraint {
|
||||
switch {
|
||||
case strings.Contains(message, "at least one of the flags"):
|
||||
return flagGroupOneRequired
|
||||
case strings.Contains(message, "must all be set"):
|
||||
return flagGroupRequiredTogether
|
||||
case strings.Contains(message, "none of the others can be"):
|
||||
return flagGroupMutuallyExclusive
|
||||
default:
|
||||
return flagGroupConstraint(-1)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
|
||||
// with a typed *errs.ValidationError: a flag that belongs to a missing
|
||||
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
|
||||
@@ -592,17 +679,21 @@ func isLarkDomain(c *cobra.Command) bool {
|
||||
return cmdmeta.Domain(c) != ""
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into a typed validation envelope: an
|
||||
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
||||
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
|
||||
// nothing) and the offending flag in `params`. Other flag errors stay typed
|
||||
// but generic.
|
||||
// flagDidYouMean is the single FlagErrorFunc inherited by all commands. It
|
||||
// classifies pflag's typed parse errors and emits one stable validation shape.
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
if ferr == nil {
|
||||
return nil
|
||||
}
|
||||
var notExist *pflag.NotExistError
|
||||
if !errors.As(ferr, ¬Exist) {
|
||||
return typedFlagParseError(c, ferr)
|
||||
}
|
||||
|
||||
name := notExist.GetSpecifiedName()
|
||||
rawName := "--" + name
|
||||
if notExist.GetSpecifiedShortnames() != "" {
|
||||
rawName = "-" + name
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
@@ -614,36 +705,69 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
|
||||
strings.Join(suggestions, ", "), c.CommandPath())
|
||||
}
|
||||
// The ranked candidates ride on the param as machine-readable Suggestions so
|
||||
// an agent can retry without parsing the hint; the hint carries the same
|
||||
// candidates as prose. The full valid-flag list stays recoverable via --help.
|
||||
if cmdmeta.Domain(c) == "sheets" {
|
||||
if list := inlineVisibleFlags(valid); list != "" {
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean %s? valid flags: %s", strings.Join(suggestions, ", "), list)
|
||||
} else {
|
||||
hint = "valid flags: " + list
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
"unknown flag %q for %q", rawName, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: rawName, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint).
|
||||
WithCause(ferr)
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
// those structured but generic — hallucinated flags are essentially always long.
|
||||
//
|
||||
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||
// this prefix when bumping cobra.
|
||||
func unknownFlagName(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
func typedFlagParseError(c *cobra.Command, ferr error) error {
|
||||
param := ""
|
||||
reason := "flag parse error"
|
||||
var valueRequired *pflag.ValueRequiredError
|
||||
var invalidValue *pflag.InvalidValueError
|
||||
var invalidSyntax *pflag.InvalidSyntaxError
|
||||
switch {
|
||||
case errors.As(ferr, &valueRequired):
|
||||
param = "--" + valueRequired.GetSpecifiedName()
|
||||
if valueRequired.GetSpecifiedShortnames() != "" {
|
||||
param = "-" + valueRequired.GetSpecifiedName()
|
||||
}
|
||||
reason = "flag value is required"
|
||||
case errors.As(ferr, &invalidValue):
|
||||
if invalidValue.GetFlag() != nil {
|
||||
param = "--" + invalidValue.GetFlag().Name
|
||||
}
|
||||
reason = "invalid flag value"
|
||||
case errors.As(ferr, &invalidSyntax):
|
||||
param = invalidSyntax.GetSpecifiedFlag()
|
||||
reason = "invalid flag syntax"
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath()).
|
||||
WithCause(ferr)
|
||||
if param != "" {
|
||||
validationErr.WithParams(errs.InvalidParam{Name: param, Reason: reason})
|
||||
}
|
||||
return rest, true
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func inlineVisibleFlags(names []string) string {
|
||||
const limit = 25
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
shown := names
|
||||
suffix := ""
|
||||
if len(shown) > limit {
|
||||
shown = shown[:limit]
|
||||
suffix = fmt.Sprintf(", ... (%d more; see --help)", len(names)-limit)
|
||||
}
|
||||
flags := make([]string, len(shown))
|
||||
for i, name := range shown {
|
||||
flags[i] = "--" + name
|
||||
}
|
||||
return strings.Join(flags, ", ") + suffix
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
||||
|
||||
111
cmd/root_test.go
111
cmd/root_test.go
@@ -6,6 +6,7 @@ package cmd
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -284,9 +285,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
|
||||
// errs.* error, so it reaches the deprecation fallback.
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
err := errs.NewValidationError(errs.SubtypeInvalidArgument, `required flag(s) %q not set`, "values").
|
||||
WithParam("--values")
|
||||
exit := handleRootError(f, err)
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -381,10 +382,9 @@ func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
|
||||
return errObj
|
||||
}
|
||||
|
||||
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
|
||||
// usage error (missing required flag) is typed as invalid_argument with exit 2
|
||||
// even with no deprecation pending — never cobra's plain "Error:" line.
|
||||
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
||||
// TestCobraValidationGuardTypesRequiredFlag pins that required-flag errors are
|
||||
// typed at the Cobra validation stage, before the final dispatcher.
|
||||
func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
@@ -393,7 +393,15 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
cmd.Flags().String("values", "", "")
|
||||
cmd.MarkFlagRequired("values")
|
||||
installCobraValidationGuards(cmd)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing required flag error")
|
||||
}
|
||||
exit := handleRootError(f, err)
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -411,6 +419,93 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCobraValidationGuardTypesFlagGroupErrorsWithParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mark func(*cobra.Command)
|
||||
args []string
|
||||
wantNames []string
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "one required",
|
||||
mark: func(cmd *cobra.Command) {
|
||||
cmd.MarkFlagsOneRequired("start-cell", "range")
|
||||
},
|
||||
wantNames: []string{"--start-cell", "--range"},
|
||||
wantReason: "one of [--start-cell --range] required",
|
||||
},
|
||||
{
|
||||
name: "required together",
|
||||
mark: func(cmd *cobra.Command) {
|
||||
cmd.MarkFlagsRequiredTogether("start-cell", "range")
|
||||
},
|
||||
args: []string{"--start-cell", "A1"},
|
||||
wantNames: []string{"--start-cell", "--range"},
|
||||
wantReason: "all of [--start-cell --range] required together",
|
||||
},
|
||||
{
|
||||
name: "mutually exclusive",
|
||||
mark: func(cmd *cobra.Command) {
|
||||
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
|
||||
},
|
||||
args: []string{"--start-cell", "A1", "--range", "B2"},
|
||||
wantNames: []string{"--start-cell", "--range"},
|
||||
wantReason: "only one of [--start-cell --range] allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
cmd.Flags().String("start-cell", "", "")
|
||||
cmd.Flags().String("range", "", "")
|
||||
tt.mark(cmd)
|
||||
installCobraValidationGuards(cmd)
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected flag group error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if len(validationErr.Params) != len(tt.wantNames) {
|
||||
t.Fatalf("params = %v, want %d entries", validationErr.Params, len(tt.wantNames))
|
||||
}
|
||||
for i, wantName := range tt.wantNames {
|
||||
if validationErr.Params[i].Name != wantName || validationErr.Params[i].Reason != tt.wantReason {
|
||||
t.Errorf("params[%d] = %+v, want name=%q reason=%q", i, validationErr.Params[i], wantName, tt.wantReason)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidFlagGroupParamsFromMessage(t *testing.T) {
|
||||
got := invalidFlagGroupParams("at least one of the flags in the group [start-cell range] is required")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("params = %v, want two entries", got)
|
||||
}
|
||||
for i, want := range []string{"--start-cell", "--range"} {
|
||||
if got[i].Name != want || got[i].Reason != "one of [--start-cell --range] required" {
|
||||
t.Errorf("params[%d] = %+v", i, got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
|
||||
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
|
||||
// typed boundary from a helper) is classified as an internal fault (exit 5),
|
||||
|
||||
@@ -140,6 +140,7 @@ type ServiceMethodOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JSON bool
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
@@ -268,6 +269,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.As = core.Identity(asStr)
|
||||
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Format = format
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -299,8 +305,8 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if spec.risk == cmdutil.RiskHighRiskWrite {
|
||||
@@ -311,7 +317,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
@@ -420,10 +426,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
format, formatOK := output.ParseFormat(opts.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
format, _ := output.ParseFormat(opts.Format)
|
||||
|
||||
// Scope-insufficient (99991679) and all other Lark API codes route through
|
||||
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
|
||||
@@ -706,6 +709,24 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatPretty:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatPretty)
|
||||
return apiErr
|
||||
}
|
||||
scanResult := output.ScanForSafety(commandPath, result, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
output.FormatValue(out, result, output.FormatPretty)
|
||||
return nil
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
|
||||
@@ -201,6 +201,42 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_OutputFormatResolution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "json shorthand", args: []string{"--json"}, want: "json"},
|
||||
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
|
||||
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
args := []string{"--as", "bot"}
|
||||
cmd.SetArgs(append(args, tt.args...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("expected options to be captured")
|
||||
}
|
||||
if captured.Format != tt.want {
|
||||
t.Fatalf("format = %q, want %q", captured.Format, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── dry-run / buildServiceRequest ──
|
||||
|
||||
func TestServiceMethod_DryRun_PathParam(t *testing.T) {
|
||||
@@ -462,6 +498,43 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PrettyFormatsRealResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("pretty output data = %#v", got["data"])
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("pretty output data.items = %#v", data["items"])
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
|
||||
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
||||
@@ -503,6 +576,48 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-service-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("page-all pretty output data = %#v", got["data"])
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
|
||||
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
type serviceContentSafetyProvider struct {
|
||||
called bool
|
||||
path string
|
||||
@@ -795,26 +910,23 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
func TestServiceMethod_UnknownFormat_ReturnsValidationError(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
err := cmd.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: unknown format") {
|
||||
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
|
||||
if validationErr.Param != "--format" {
|
||||
t.Errorf("param = %q, want --format", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,38 @@ func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) {
|
||||
if files.RunE == nil {
|
||||
t.Error("files should have RunE installed")
|
||||
}
|
||||
if root.Args == nil {
|
||||
t.Error("root should explicitly accept positional tokens so unknown commands reach RunE")
|
||||
}
|
||||
if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil {
|
||||
t.Errorf("leaf +search RunE should be untouched, got error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopLevelUnknownCommandReturnsStructuredSuggestion(t *testing.T) {
|
||||
root, _, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(root)
|
||||
root.SetArgs([]string{"driv"})
|
||||
|
||||
err := root.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "driv" {
|
||||
t.Fatalf("params = %v, want one entry named driv", validationErr.Params)
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range validationErr.Params[0].Suggestions {
|
||||
if candidate == "drive" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("suggestions = %v, want drive", validationErr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
called := false
|
||||
|
||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/cobra v1.10.2 // typed flag errors are classified in cmd/root.go
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
61
internal/output/capabilities.go
Normal file
61
internal/output/capabilities.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// FormatCapabilities is the single description of the output formats a
|
||||
// command supports. Help text, shell completion, shorthand normalization and
|
||||
// runtime validation all consume the same value so they cannot drift apart.
|
||||
type FormatCapabilities struct {
|
||||
names []string
|
||||
}
|
||||
|
||||
var (
|
||||
// StandardFormats applies to API, service and ordinary shortcut commands.
|
||||
StandardFormats = NewFormatCapabilities("json", "pretty", "table", "ndjson", "csv")
|
||||
// JSONPrettyFormats applies to commands with a dedicated human renderer and
|
||||
// no streaming/tabular output, such as auth scopes.
|
||||
JSONPrettyFormats = NewFormatCapabilities("json", "pretty")
|
||||
)
|
||||
|
||||
// NewFormatCapabilities constructs an immutable format capability set.
|
||||
func NewFormatCapabilities(names ...string) FormatCapabilities {
|
||||
return FormatCapabilities{names: append([]string(nil), names...)}
|
||||
}
|
||||
|
||||
// Names returns a copy suitable for completion candidates.
|
||||
func (c FormatCapabilities) Names() []string {
|
||||
return append([]string(nil), c.names...)
|
||||
}
|
||||
|
||||
// Usage returns the canonical help text for a --format flag.
|
||||
func (c FormatCapabilities) Usage() string {
|
||||
return "output format: " + strings.Join(c.names, "|")
|
||||
}
|
||||
|
||||
// Supports reports whether name is part of this command's output contract.
|
||||
func (c FormatCapabilities) Supports(name string) bool {
|
||||
return slices.Contains(c.names, strings.ToLower(name))
|
||||
}
|
||||
|
||||
// Resolve applies the --json shorthand and validates the selected format.
|
||||
// An explicit --format always wins over --json.
|
||||
func (c FormatCapabilities) Resolve(format string, formatExplicit, jsonShorthand bool) (string, error) {
|
||||
if jsonShorthand && !formatExplicit {
|
||||
format = "json"
|
||||
}
|
||||
format = strings.ToLower(format)
|
||||
if c.Supports(format) {
|
||||
return format, nil
|
||||
}
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported output format %q; supported formats: %s", format, strings.Join(c.names, ", ")).
|
||||
WithParam("--format")
|
||||
}
|
||||
53
internal/output/capabilities_test.go
Normal file
53
internal/output/capabilities_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestFormatCapabilitiesResolve(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
formatExplicit bool
|
||||
jsonShorthand bool
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "default", format: "json", want: "json"},
|
||||
{name: "json shorthand", format: "table", jsonShorthand: true, want: "json"},
|
||||
{name: "explicit format wins", format: "table", formatExplicit: true, jsonShorthand: true, want: "table"},
|
||||
{name: "case normalized", format: "PRETTY", formatExplicit: true, want: "pretty"},
|
||||
{name: "unsupported", format: "xml", formatExplicit: true, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := StandardFormats.Resolve(tt.format, tt.formatExplicit, tt.jsonShorthand)
|
||||
if tt.wantErr {
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" {
|
||||
t.Fatalf("Resolve() error = %T %v, want invalid_argument --format validation error", err, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != tt.want {
|
||||
t.Fatalf("Resolve() = %q, %v; want %q, nil", got, err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCapabilitiesDriveHelpAndCompletion(t *testing.T) {
|
||||
if got, want := StandardFormats.Usage(), "output format: json|pretty|table|ndjson|csv"; got != want {
|
||||
t.Fatalf("Usage() = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := StandardFormats.Names(), []string{"json", "pretty", "table", "ndjson", "csv"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Names() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,9 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
func FormatValue(w io.Writer, data interface{}, format Format) {
|
||||
data = toGeneric(data)
|
||||
switch format {
|
||||
case FormatPretty:
|
||||
PrintJson(w, data)
|
||||
|
||||
case FormatNDJSON:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
@@ -149,6 +152,9 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
|
||||
// FormatPage formats one page of items.
|
||||
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
|
||||
switch pf.Format {
|
||||
case FormatPretty:
|
||||
PrintJson(pf.W, data)
|
||||
|
||||
case FormatJSON, FormatNDJSON:
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
PrintNdjson(pf.W, arr)
|
||||
|
||||
@@ -73,6 +73,30 @@ func TestFormatValue_Table(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_Pretty(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, FormatPretty)
|
||||
out := buf.String()
|
||||
|
||||
if !json.Valid([]byte(out)) {
|
||||
t.Fatalf("pretty output should be valid JSON, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || !strings.Contains(out, `"name": "Alice"`) {
|
||||
t.Fatalf("pretty output should be indented JSON, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "─") {
|
||||
t.Fatalf("pretty output should not render a table, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_CSV(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
@@ -149,6 +173,20 @@ func TestPaginatedFormatter_Table(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_Pretty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatPretty)
|
||||
|
||||
pf.FormatPage([]interface{}{map[string]interface{}{"name": "Alice"}})
|
||||
out := buf.String()
|
||||
if !json.Valid([]byte(out)) {
|
||||
t.Fatalf("paginated pretty output should be valid JSON, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\n {") || !strings.Contains(out, `"name": "Alice"`) {
|
||||
t.Fatalf("paginated pretty output should be indented JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_CSV(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatCSV)
|
||||
|
||||
@@ -10,6 +10,7 @@ type Format int
|
||||
|
||||
const (
|
||||
FormatJSON Format = iota
|
||||
FormatPretty
|
||||
FormatNDJSON
|
||||
FormatTable
|
||||
FormatCSV
|
||||
@@ -22,6 +23,8 @@ func ParseFormat(s string) (Format, bool) {
|
||||
switch strings.ToLower(s) {
|
||||
case "json", "":
|
||||
return FormatJSON, true
|
||||
case "pretty":
|
||||
return FormatPretty, true
|
||||
case "ndjson":
|
||||
return FormatNDJSON, true
|
||||
case "table":
|
||||
@@ -36,6 +39,8 @@ func ParseFormat(s string) (Format, bool) {
|
||||
// String returns the string representation of a Format.
|
||||
func (f Format) String() string {
|
||||
switch f {
|
||||
case FormatPretty:
|
||||
return "pretty"
|
||||
case FormatNDJSON:
|
||||
return "ndjson"
|
||||
case FormatTable:
|
||||
|
||||
@@ -14,6 +14,9 @@ func TestParseFormat(t *testing.T) {
|
||||
{"json", FormatJSON, true},
|
||||
{"JSON", FormatJSON, true},
|
||||
{"Json", FormatJSON, true},
|
||||
{"pretty", FormatPretty, true},
|
||||
{"PRETTY", FormatPretty, true},
|
||||
{"Pretty", FormatPretty, true},
|
||||
{"ndjson", FormatNDJSON, true},
|
||||
{"NDJSON", FormatNDJSON, true},
|
||||
{"Ndjson", FormatNDJSON, true},
|
||||
@@ -52,6 +55,7 @@ func TestFormatString(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{FormatJSON, "json"},
|
||||
{FormatPretty, "pretty"},
|
||||
{FormatNDJSON, "ndjson"},
|
||||
{FormatTable, "table"},
|
||||
{FormatCSV, "csv"},
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
rootcmd "github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
@@ -94,7 +95,7 @@ func commandFromCobra(c *cobra.Command, defaultFields map[string][]string) manif
|
||||
Short: c.Short,
|
||||
Example: c.Example,
|
||||
Hidden: c.Hidden,
|
||||
Runnable: c.Runnable(),
|
||||
Runnable: c.Runnable() && !cmdpolicy.IsPureGroup(c),
|
||||
Source: source,
|
||||
Generated: cmdmeta.Generated(c),
|
||||
Identities: cmdmeta.Identities(c),
|
||||
|
||||
@@ -90,6 +90,20 @@ func TestCollectContainsDocsFetchAndDryRunFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMarksPureNavigationGroupsNonRunnable(t *testing.T) {
|
||||
got, err := collectCommandIndex(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collectCommandIndex() error = %v", err)
|
||||
}
|
||||
cmd := findManifestCommand(&got, "approval")
|
||||
if cmd == nil {
|
||||
t.Fatalf("approval group not found")
|
||||
}
|
||||
if cmd.Runnable {
|
||||
t.Fatalf("approval is a navigation group and must not be exported as runnable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectExcludesGeneratedServiceCommands(t *testing.T) {
|
||||
got, err := collectHandAuthored(context.Background())
|
||||
if err != nil {
|
||||
|
||||
@@ -1222,7 +1222,7 @@ func TestAgenda_Success(t *testing.T) {
|
||||
"+agenda",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--format", "prettry",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
|
||||
@@ -752,12 +752,9 @@ func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, pre
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
|
||||
outFn := ctx.Out
|
||||
if raw {
|
||||
outFn = ctx.OutRaw
|
||||
}
|
||||
emitJSON := func() { ctx.emit(data, meta, raw, true) }
|
||||
if ctx.JqExpr != "" {
|
||||
outFn(data, meta)
|
||||
emitJSON()
|
||||
return
|
||||
}
|
||||
switch ctx.Format {
|
||||
@@ -773,10 +770,10 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
|
||||
if prettyFn != nil {
|
||||
prettyFn(ctx.IO().Out)
|
||||
} else {
|
||||
outFn(data, meta)
|
||||
output.FormatValue(ctx.IO().Out, data, output.FormatPretty)
|
||||
}
|
||||
case "json", "":
|
||||
outFn(data, meta)
|
||||
emitJSON()
|
||||
default:
|
||||
// table, csv, ndjson — pass data directly; FormatValue handles both
|
||||
// plain arrays and maps with array fields (e.g. {"members":[…]})
|
||||
@@ -790,7 +787,11 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
|
||||
}
|
||||
format, formatOK := output.ParseFormat(ctx.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
|
||||
ctx.outputErrOnce.Do(func() {
|
||||
ctx.outputErr = errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported output format %q", ctx.Format).WithParam("--format")
|
||||
})
|
||||
return
|
||||
}
|
||||
output.FormatValue(ctx.IO().Out, data, format)
|
||||
}
|
||||
@@ -928,6 +929,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
}
|
||||
}
|
||||
|
||||
// Output format validation is local and must happen before identity,
|
||||
// configuration or credential work. Invalid input therefore produces the
|
||||
// same typed validation error even when no account is configured.
|
||||
if _, err := normalizeShortcutOutputFormat(cmd, s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1027,8 +1035,11 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
}
|
||||
rctx.larkSDK = sdk
|
||||
|
||||
applyJSONShorthand(cmd, s)
|
||||
rctx.Format = rctx.Str("format")
|
||||
format, err := normalizeShortcutOutputFormat(cmd, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rctx.Format = format
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
}
|
||||
@@ -1200,15 +1211,23 @@ func shortcutDeclaresJSONFlag(s *Shortcut) bool {
|
||||
}
|
||||
|
||||
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
|
||||
// "json": a self-declared format supports it only when its Enum lists "json";
|
||||
// a framework-injected default format (no format entry in s.Flags) always does.
|
||||
// "json". It derives the answer from the same capability set used everywhere
|
||||
// else (shortcutFormatCapabilities), so a format flag that declares no Enum but
|
||||
// defaults to "json" is correctly recognized as JSON-capable.
|
||||
func shortcutFormatSupportsJSON(s *Shortcut) bool {
|
||||
return shortcutFormatCapabilities(s).Supports("json")
|
||||
}
|
||||
|
||||
func shortcutFormatCapabilities(s *Shortcut) output.FormatCapabilities {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "format" {
|
||||
return slices.Contains(fl.Enum, "json")
|
||||
if len(fl.Enum) > 0 {
|
||||
return output.NewFormatCapabilities(fl.Enum...)
|
||||
}
|
||||
return output.NewFormatCapabilities(fl.Default)
|
||||
}
|
||||
}
|
||||
return true // framework-injected: json (default) | pretty | table | ndjson | csv
|
||||
return output.StandardFormats
|
||||
}
|
||||
|
||||
// ensureJSONShorthand registers --json as a shorthand for --format json when:
|
||||
@@ -1243,15 +1262,32 @@ func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
// shorthand only fills in when the user did not choose a format). Shortcuts
|
||||
// that declare their own "json" flag keep its custom semantics untouched.
|
||||
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
_, _ = normalizeShortcutOutputFormat(cmd, s)
|
||||
}
|
||||
|
||||
func normalizeShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) {
|
||||
format, err := resolveShortcutOutputFormat(cmd, s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
|
||||
return
|
||||
if cmd.Flags().Lookup("format") != nil && cmd.Flags().Lookup("format").Value.String() != format {
|
||||
if err := cmd.Flags().Set("format", format); err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to normalize --format: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
if set, _ := cmd.Flags().GetBool("json"); set {
|
||||
_ = cmd.Flags().Set("format", "json")
|
||||
return format, nil
|
||||
}
|
||||
|
||||
func resolveShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) {
|
||||
format, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to read --format: %v", err).WithCause(err)
|
||||
}
|
||||
jsonShorthand := false
|
||||
if !shortcutDeclaresJSONFlag(s) && cmd.Flags().Lookup("json") != nil {
|
||||
jsonShorthand, _ = cmd.Flags().GetBool("json")
|
||||
}
|
||||
return shortcutFormatCapabilities(s).Resolve(format, cmd.Flags().Changed("format"), jsonShorthand)
|
||||
}
|
||||
|
||||
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
||||
@@ -1313,9 +1349,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
|
||||
cmd.Flags().Bool("dry-run", false, "print request without executing")
|
||||
if cmd.Flags().Lookup("format") == nil {
|
||||
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
|
||||
cmd.Flags().String("format", "json", output.StandardFormats.Usage())
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
ensureJSONShorthand(cmd, s)
|
||||
|
||||
@@ -5,9 +5,13 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -37,3 +41,87 @@ func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) {
|
||||
t.Errorf("--format default = %q, want %q", flag.DefValue, "json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContextOutKeepsJSONEnvelopeForPrettyFormat(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot)
|
||||
rctx.Format = "pretty"
|
||||
|
||||
rctx.Out(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
|
||||
}, nil)
|
||||
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("Out should emit a JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !envelope.OK || len(envelope.Data.Items) != 1 || envelope.Data.Items[0]["name"] != "Alice" {
|
||||
t.Fatalf("unexpected envelope: %#v", envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContextOutRawKeepsJSONEnvelopeForPrettyFormat(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot)
|
||||
rctx.Format = "pretty"
|
||||
|
||||
rctx.OutRaw(map[string]interface{}{"body": "<p>hello</p>"}, nil)
|
||||
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("OutRaw should emit a JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !envelope.OK || envelope.Data.Body != "<p>hello</p>" {
|
||||
t.Fatalf("unexpected envelope: %#v", envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_UnsupportedFormatFailsBeforeExecution(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
executed := false
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+read",
|
||||
Description: "read data",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
executed = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected mounted shortcut command")
|
||||
}
|
||||
parent.SetArgs([]string{"+read", "--format", "xml"})
|
||||
err = parent.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--format" {
|
||||
t.Fatalf("param = %q, want --format", validationErr.Param)
|
||||
}
|
||||
if executed {
|
||||
t.Fatal("shortcut must not execute with an unsupported format")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// flagName is a package-private snapshot of a pflag.Flag's identity.
|
||||
type flagName struct {
|
||||
long, short string
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// Candidate is a single suggested flag returned to the user when an
|
||||
// unknown flag is detected.
|
||||
type Candidate struct {
|
||||
// Flag is the long-form spelling of the suggested flag, e.g. "--to".
|
||||
Flag string `json:"flag"`
|
||||
// Shorthand is the single-character shorthand (without the leading
|
||||
// dash) when the suggested flag has one; empty otherwise.
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
// Distance is the Levenshtein edit distance to the unknown token.
|
||||
// Zero indicates a bidirectional prefix hit (Reason == "prefix").
|
||||
Distance int `json:"distance"`
|
||||
// Reason explains how the candidate was matched: "prefix" for
|
||||
// bidirectional prefix hits, "edit_distance" for fuzzy matches.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// maxCandidates caps the number of suggestions returned per error so
|
||||
// the JSON envelope stays compact and the user-visible hint remains
|
||||
// scannable.
|
||||
const maxCandidates = 5
|
||||
|
||||
// InstallOnMail attaches the unknown-flag fuzzy-match hook on the mail
|
||||
// service cobra parent command. It is invoked exactly once from
|
||||
// shortcuts/register.go inside the `service == "mail"` branch.
|
||||
//
|
||||
// Cobra's FlagErrorFunc walks up the parent chain looking for the nearest
|
||||
// non-nil hook, so every mail subcommand inherits this behaviour without
|
||||
// any per-shortcut wiring.
|
||||
func InstallOnMail(svc *cobra.Command) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
svc.SetFlagErrorFunc(flagSuggestErrorFunc)
|
||||
}
|
||||
|
||||
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a typed
|
||||
// validation error carrying candidate suggestions. Any other error is passed
|
||||
// through unchanged so cobra's existing handling kicks in.
|
||||
func flagSuggestErrorFunc(c *cobra.Command, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
token, isShorthand, ok := parseUnknownToken(err.Error())
|
||||
if !ok {
|
||||
// Non unknown-flag errors (e.g. "required flag(s) ... not set")
|
||||
// pass through to cmd/root.go::handleRootError's fallback path.
|
||||
return err
|
||||
}
|
||||
names := collectFlags(c)
|
||||
var matches []Candidate
|
||||
if isShorthand {
|
||||
matches = suggestShorthand(token, names)
|
||||
} else {
|
||||
matches = suggest(token, names)
|
||||
}
|
||||
// Normalise to a non-nil slice so the JSON envelope always emits
|
||||
// `candidates: []` instead of `null`, keeping the wire shape stable
|
||||
// for downstream parsers regardless of command-state.
|
||||
if matches == nil {
|
||||
matches = []Candidate{}
|
||||
}
|
||||
hint := buildHint(c, matches)
|
||||
params := []errs.InvalidParam{{
|
||||
Name: rawUnknownToken(token, isShorthand),
|
||||
Reason: "unknown flag",
|
||||
}}
|
||||
for _, match := range matches {
|
||||
reason := fmt.Sprintf("candidate (%s, distance=%d)", match.Reason, match.Distance)
|
||||
if match.Shorthand != "" {
|
||||
reason += fmt.Sprintf(", shorthand=-%s", match.Shorthand)
|
||||
}
|
||||
params = append(params, errs.InvalidParam{Name: match.Flag, Reason: reason})
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, err.Error()).
|
||||
WithHint("%s", hint).
|
||||
WithParam(rawUnknownToken(token, isShorthand)).
|
||||
WithParams(params...)
|
||||
}
|
||||
|
||||
// parseUnknownToken extracts the offending flag name from a pflag error
|
||||
// string. Recognised forms:
|
||||
//
|
||||
// - "unknown flag: --tos"
|
||||
// - "unknown flag: --bogus=val"
|
||||
// - "unknown shorthand flag: 'X' in -Xyz"
|
||||
//
|
||||
// Anything else returns (_, _, false) so the caller can pass the error
|
||||
// through unchanged.
|
||||
func parseUnknownToken(errMsg string) (token string, isShorthand bool, ok bool) {
|
||||
const longPrefix = "unknown flag: --"
|
||||
const shortPrefix = "unknown shorthand flag: '"
|
||||
switch {
|
||||
case strings.HasPrefix(errMsg, longPrefix):
|
||||
rest := errMsg[len(longPrefix):]
|
||||
if eq := strings.IndexAny(rest, "= \t"); eq >= 0 {
|
||||
rest = rest[:eq]
|
||||
}
|
||||
return rest, false, rest != ""
|
||||
case strings.HasPrefix(errMsg, shortPrefix):
|
||||
rest := errMsg[len(shortPrefix):]
|
||||
end := strings.IndexByte(rest, '\'')
|
||||
if end <= 0 {
|
||||
return "", false, false
|
||||
}
|
||||
return rest[:end], true, true
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// rawUnknownToken re-attaches the leading dash(es) to a bare token so the
|
||||
// JSON envelope echoes the user-visible spelling.
|
||||
func rawUnknownToken(token string, isShorthand bool) string {
|
||||
if isShorthand {
|
||||
return "-" + token
|
||||
}
|
||||
return "--" + token
|
||||
}
|
||||
|
||||
// collectFlags snapshots the merged local + persistent + inherited flag
|
||||
// set of cmd. The hidden bit is preserved on each entry; the suggest
|
||||
// helpers apply the actual filter so the slice stays reusable.
|
||||
func collectFlags(cmd *cobra.Command) []flagName {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
var out []flagName
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
out = append(out, flagName{long: f.Name, short: f.Shorthand, hidden: f.Hidden})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// suggest produces top-N long-flag candidates for an unknown token, using
|
||||
// bidirectional prefix matching first and Levenshtein distance for the
|
||||
// remainder. Hidden flags and empty long names are skipped. Results are
|
||||
// stably sorted by (Distance asc, Flag asc) and capped at maxCandidates.
|
||||
func suggest(unknown string, names []flagName) []Candidate {
|
||||
if unknown == "" || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
threshold := levThreshold(unknown)
|
||||
out := make([]Candidate, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
|
||||
// Priority 1: bidirectional prefix match.
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(n.long, unknown) || strings.HasPrefix(unknown, n.long) {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
seen[n.long] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Priority 2: Levenshtein distance, skipping already-matched names.
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n.long]; ok {
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(unknown, n.long); d <= threshold {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: d, Reason: "edit_distance"})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Distance != out[j].Distance {
|
||||
return out[i].Distance < out[j].Distance
|
||||
}
|
||||
return out[i].Flag < out[j].Flag
|
||||
})
|
||||
if len(out) > maxCandidates {
|
||||
out = out[:maxCandidates]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestShorthand produces candidates for an unknown single-character
|
||||
// shorthand. It first looks for exact f.Shorthand matches; if there are
|
||||
// none, it falls back to long names that begin with the same character.
|
||||
// Levenshtein is deliberately not used here since single-char edit
|
||||
// distance would match almost every flag.
|
||||
func suggestShorthand(c string, names []flagName) []Candidate {
|
||||
if c == "" || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Candidate, 0)
|
||||
for _, n := range names {
|
||||
if n.hidden {
|
||||
continue
|
||||
}
|
||||
if n.short == c {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(n.long, c) {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Flag < out[j].Flag })
|
||||
if len(out) > maxCandidates {
|
||||
out = out[:maxCandidates]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildHint returns a one-line hint suitable for a typed error's Hint field.
|
||||
// When at least one candidate exists, the top hit is named; otherwise
|
||||
// the user is directed to --help.
|
||||
func buildHint(c *cobra.Command, matches []Candidate) string {
|
||||
if len(matches) == 0 {
|
||||
return fmt.Sprintf("Run `%s --help` to view available flags", c.CommandPath())
|
||||
}
|
||||
return fmt.Sprintf("Did you mean: %s ?", matches[0].Flag)
|
||||
}
|
||||
|
||||
// levThreshold returns the maximum acceptable Levenshtein distance for a
|
||||
// token of the given length, clamped to [1, 4].
|
||||
func levThreshold(s string) int {
|
||||
t := len(s)/3 + 1
|
||||
if t < 1 {
|
||||
return 1
|
||||
}
|
||||
if t > 4 {
|
||||
return 4
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// levenshtein computes the standard Levenshtein edit distance between
|
||||
// two ASCII strings using a 2-row dynamic-programming table.
|
||||
func levenshtein(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
prev := make([]int, lb+1)
|
||||
curr := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// --- suggest (long-flag) ---
|
||||
|
||||
func TestSuggest_Prefix(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "to", short: "t"},
|
||||
{long: "cc"},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggest("tos", names)
|
||||
require.NotEmpty(t, got)
|
||||
// "tos" has --to as a prefix → bidirectional prefix hit, Distance=0.
|
||||
assert.Equal(t, "--to", got[0].Flag)
|
||||
assert.Equal(t, 0, got[0].Distance)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
}
|
||||
|
||||
func TestSuggest_Levenshtein(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "subject"},
|
||||
{long: "body"},
|
||||
{long: "to"},
|
||||
}
|
||||
// Distance 1 from "subject".
|
||||
got := suggest("subjec", names)
|
||||
require.NotEmpty(t, got)
|
||||
// "subjec" is prefix of "subject" → bidirectional prefix.
|
||||
assert.Equal(t, "--subject", got[0].Flag)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
|
||||
// True edit-distance: "subjeect" is not a prefix either way of "subject".
|
||||
got = suggest("subjeect", names)
|
||||
require.NotEmpty(t, got)
|
||||
assert.Equal(t, "--subject", got[0].Flag)
|
||||
assert.Equal(t, "edit_distance", got[0].Reason)
|
||||
assert.GreaterOrEqual(t, got[0].Distance, 1)
|
||||
}
|
||||
|
||||
func TestSuggest_HiddenSkipped(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "internal-debug", hidden: true},
|
||||
{long: "interactive"},
|
||||
}
|
||||
got := suggest("internal", names)
|
||||
for _, c := range got {
|
||||
assert.NotEqual(t, "--internal-debug", c.Flag, "hidden flag must not appear in suggestions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggest_TopNAndStableSort(t *testing.T) {
|
||||
// 6 names all within threshold and at the same distance (1) from the
|
||||
// unknown token so that the lexicographic tiebreak and maxCandidates
|
||||
// cap are both exercised. (Earlier the names were 3-distance from
|
||||
// "zzz" which is above the threshold of 2 — suggest returned empty
|
||||
// and the assertions trivially passed.)
|
||||
names := []flagName{
|
||||
{long: "aaab"},
|
||||
{long: "aaac"},
|
||||
{long: "aaad"},
|
||||
{long: "aaae"},
|
||||
{long: "aaaf"},
|
||||
{long: "aaag"},
|
||||
}
|
||||
got := suggest("aaaa", names)
|
||||
require.Len(t, got, maxCandidates, "must cap at maxCandidates")
|
||||
// All distances equal → lex ordering by Flag asc, top 5 alphabetically.
|
||||
wantFlags := []string{"--aaab", "--aaac", "--aaad", "--aaae", "--aaaf"}
|
||||
gotFlags := []string{got[0].Flag, got[1].Flag, got[2].Flag, got[3].Flag, got[4].Flag}
|
||||
assert.Equal(t, wantFlags, gotFlags, "tiebreak must order by Flag asc")
|
||||
}
|
||||
|
||||
// --- suggestShorthand ---
|
||||
|
||||
func TestSuggestShorthand_Exact(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "to", short: "t"},
|
||||
{long: "cc", short: "c"},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggestShorthand("t", names)
|
||||
require.NotEmpty(t, got)
|
||||
assert.Equal(t, "--to", got[0].Flag)
|
||||
assert.Equal(t, "t", got[0].Shorthand)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_PrefixFallback(t *testing.T) {
|
||||
// No short matches "x"; fall back to long names starting with "x".
|
||||
names := []flagName{
|
||||
{long: "xargs"},
|
||||
{long: "xterm"},
|
||||
{long: "yargs"},
|
||||
}
|
||||
got := suggestShorthand("x", names)
|
||||
require.NotEmpty(t, got)
|
||||
flags := make([]string, 0, len(got))
|
||||
for _, c := range got {
|
||||
flags = append(flags, c.Flag)
|
||||
}
|
||||
assert.Contains(t, flags, "--xargs")
|
||||
assert.Contains(t, flags, "--xterm")
|
||||
assert.NotContains(t, flags, "--yargs")
|
||||
}
|
||||
|
||||
// --- parseUnknownToken ---
|
||||
|
||||
func TestParseUnknownToken_Long(t *testing.T) {
|
||||
tok, isShort, ok := parseUnknownToken("unknown flag: --tos")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "tos", tok)
|
||||
|
||||
tok, isShort, ok = parseUnknownToken("unknown flag: --bogus=val")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "bogus", tok, "must strip =value tail")
|
||||
|
||||
tok, _, ok = parseUnknownToken("unknown flag: --bogus value")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "bogus", tok, "must strip whitespace tail")
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_Shorthand(t *testing.T) {
|
||||
tok, isShort, ok := parseUnknownToken("unknown shorthand flag: 'X' in -X")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, isShort)
|
||||
assert.Equal(t, "X", tok)
|
||||
|
||||
tok, isShort, ok = parseUnknownToken("unknown shorthand flag: 'q' in -qrs")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, isShort)
|
||||
assert.Equal(t, "q", tok)
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_NotMatch(t *testing.T) {
|
||||
cases := []string{
|
||||
`required flag(s) "to" not set`,
|
||||
"some unrelated error",
|
||||
"",
|
||||
"unknown command \"foo\" for \"mail\"",
|
||||
}
|
||||
for _, in := range cases {
|
||||
tok, isShort, ok := parseUnknownToken(in)
|
||||
assert.False(t, ok, "input %q must not match", in)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "", tok)
|
||||
}
|
||||
}
|
||||
|
||||
// --- flagSuggestErrorFunc ---
|
||||
|
||||
// newFakeMailCmd builds a cobra command tree resembling the mail parent
|
||||
// with a handful of flags exercised by the hook tests.
|
||||
func newFakeMailCmd() *cobra.Command {
|
||||
c := &cobra.Command{Use: "mail"}
|
||||
c.Flags().String("to", "", "recipients")
|
||||
c.Flags().String("cc", "", "cc recipients")
|
||||
c.Flags().String("subject", "", "subject")
|
||||
c.Flags().StringP("body", "b", "", "body")
|
||||
return c
|
||||
}
|
||||
|
||||
func requireFlagSuggestValidation(t *testing.T, got error) *errs.ValidationError {
|
||||
t.Helper()
|
||||
var validationErr *errs.ValidationError
|
||||
require.True(t, errors.As(got, &validationErr), "expected *errs.ValidationError, got %T", got)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
require.True(t, ok, "expected typed Problem")
|
||||
assert.Equal(t, errs.CategoryValidation, p.Category)
|
||||
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func paramReason(params []errs.InvalidParam, name string) (string, bool) {
|
||||
for _, p := range params {
|
||||
if p.Name == name {
|
||||
return p.Reason, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsTypedValidation(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "unknown flag: --tos", validationErr.Message)
|
||||
assert.Equal(t, "--tos", validationErr.Param)
|
||||
assert.Contains(t, validationErr.Hint, "--to")
|
||||
|
||||
reason, ok := paramReason(validationErr.Params, "--tos")
|
||||
require.True(t, ok, "unknown flag should be included in params")
|
||||
assert.Equal(t, "unknown flag", reason)
|
||||
reason, ok = paramReason(validationErr.Params, "--to")
|
||||
require.True(t, ok, "expected --to in candidate params")
|
||||
assert.Contains(t, reason, "candidate (prefix")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
in := errors.New(`required flag(s) "to" not set`)
|
||||
got := flagSuggestErrorFunc(cmd, in)
|
||||
// Identity passthrough: same error pointer.
|
||||
assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_TypedCategoryAndSubtype(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
p, ok := errs.ProblemOf(got)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errs.CategoryValidation, p.Category)
|
||||
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
|
||||
}
|
||||
|
||||
// --- edge-case coverage ---
|
||||
|
||||
func TestInstallOnMail_NilIsNoop(t *testing.T) {
|
||||
// Must not panic; the nil-guard is the contract.
|
||||
InstallOnMail(nil)
|
||||
}
|
||||
|
||||
func TestInstallOnMail_InstallsHook(t *testing.T) {
|
||||
c := newFakeMailCmd()
|
||||
InstallOnMail(c)
|
||||
require.NotNil(t, c.FlagErrorFunc())
|
||||
got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "--tos", validationErr.Param)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
assert.NoError(t, flagSuggestErrorFunc(cmd, nil))
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "--tos", validationErr.Param, "value tail must be stripped before echoing")
|
||||
reason, ok := paramReason(validationErr.Params, "--tos")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "unknown flag", reason)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "-b", validationErr.Param)
|
||||
reason, ok := paramReason(validationErr.Params, "-b")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "unknown flag", reason)
|
||||
// newFakeMailCmd has --body/-b; exact shorthand hit expected.
|
||||
reason, ok = paramReason(validationErr.Params, "--body")
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, reason, "candidate (prefix")
|
||||
assert.Contains(t, reason, "shorthand=-b")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ParamsAlwaysPresent(t *testing.T) {
|
||||
// A cobra command with no flags forces collectFlags → empty names →
|
||||
// suggest → nil. The typed validation error must still expose the unknown
|
||||
// flag in Params so downstream parsers have a stable structured field.
|
||||
bare := &cobra.Command{Use: "mail"}
|
||||
got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.NotNil(t, validationErr.Params)
|
||||
require.Len(t, validationErr.Params, 1)
|
||||
assert.Equal(t, "--bogus", validationErr.Params[0].Name)
|
||||
assert.Equal(t, "unknown flag", validationErr.Params[0].Reason)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
// Token with no plausible neighbor in {to, cc, subject, body}.
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Contains(t, validationErr.Hint, "--help")
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) {
|
||||
// Long form with empty token after the prefix.
|
||||
_, _, ok := parseUnknownToken("unknown flag: --")
|
||||
assert.False(t, ok, "empty long token must not match")
|
||||
|
||||
// Shorthand with no closing quote.
|
||||
_, _, ok = parseUnknownToken("unknown shorthand flag: 'q")
|
||||
assert.False(t, ok, "shorthand without closing quote must not match")
|
||||
|
||||
// Shorthand with empty char between quotes.
|
||||
_, _, ok = parseUnknownToken("unknown shorthand flag: '' in -")
|
||||
assert.False(t, ok, "empty shorthand token must not match")
|
||||
}
|
||||
|
||||
func TestSuggest_EmptyInputs(t *testing.T) {
|
||||
assert.Nil(t, suggest("", []flagName{{long: "to"}}))
|
||||
assert.Nil(t, suggest("foo", nil))
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_EmptyInputs(t *testing.T) {
|
||||
assert.Nil(t, suggestShorthand("", []flagName{{long: "to", short: "t"}}))
|
||||
assert.Nil(t, suggestShorthand("x", nil))
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_HiddenSkipped(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "secret", short: "s", hidden: true},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggestShorthand("s", names)
|
||||
for _, c := range got {
|
||||
assert.NotEqual(t, "--secret", c.Flag, "hidden shorthand must not be suggested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFlags_NilSafe(t *testing.T) {
|
||||
assert.Nil(t, collectFlags(nil))
|
||||
}
|
||||
|
||||
func TestLevThreshold_Clamp(t *testing.T) {
|
||||
// len 0 → 0/3+1 = 1
|
||||
assert.Equal(t, 1, levThreshold(""))
|
||||
// len 3 → 2
|
||||
assert.Equal(t, 2, levThreshold("abc"))
|
||||
// Long token caps at 4.
|
||||
assert.Equal(t, 4, levThreshold("aaaaaaaaaaaaaaaaaaaa"))
|
||||
}
|
||||
|
||||
func TestLevenshtein_EmptyAndIdentical(t *testing.T) {
|
||||
assert.Equal(t, 0, levenshtein("", ""))
|
||||
assert.Equal(t, 3, levenshtein("", "abc"))
|
||||
assert.Equal(t, 3, levenshtein("abc", ""))
|
||||
assert.Equal(t, 0, levenshtein("abc", "abc"))
|
||||
assert.Equal(t, 1, levenshtein("abc", "abd"))
|
||||
}
|
||||
@@ -103,8 +103,8 @@ func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
|
||||
if ve.Param != "--format" {
|
||||
t.Fatalf("param = %q, want --format", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
|
||||
t.Fatalf("message = %q, want enum validation message", problem.Message)
|
||||
if !strings.Contains(problem.Message, `unsupported output format "bogus"`) {
|
||||
t.Fatalf("message = %q, want unsupported format message", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "table, json, data") {
|
||||
t.Fatalf("message = %q, want allowed values list", problem.Message)
|
||||
|
||||
@@ -157,9 +157,6 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
if service == "apps" {
|
||||
apps.InstallOnApps(svc, f)
|
||||
}
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
if service == "sheets" {
|
||||
applySheetsCompatGroups(svc)
|
||||
}
|
||||
|
||||
@@ -7,14 +7,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -400,68 +398,6 @@ func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterShortcutsInstallsMailFlagSuggestHook is the end-to-end
|
||||
// wiring guard for the mail unknown-flag fuzzy-match feature: it ensures
|
||||
// the `if service == "mail" { mail.InstallOnMail(svc) }` branch in
|
||||
// RegisterShortcutsWithContext is actually exercised, so a future refactor
|
||||
// that drops the branch (or breaks the import) will fail this test rather
|
||||
// than silently regressing the structured-error contract.
|
||||
func TestRegisterShortcutsInstallsMailFlagSuggestHook(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
mailCmd, _, err := program.Find([]string{"mail"})
|
||||
if err != nil {
|
||||
t.Fatalf("find mail command: %v", err)
|
||||
}
|
||||
if mailCmd == nil || mailCmd.Name() != "mail" {
|
||||
t.Fatalf("mail command not mounted: %#v", mailCmd)
|
||||
}
|
||||
|
||||
// The FlagErrorFunc lookup walks up to the nearest non-nil hook, so
|
||||
// invoking it on the mail parent (or any of its children) must yield
|
||||
// a typed validation problem for the unknown flag.
|
||||
got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus"))
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(got, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T (%v)", got, got)
|
||||
}
|
||||
if validationErr.Param != "--bogus" {
|
||||
t.Fatalf("expected Param=--bogus, got %q", validationErr.Param)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", got, got)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument, got %s/%s", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterShortcutsLeavesNonMailFlagErrorUntouched confirms the
|
||||
// install is scoped: a non-mail service must keep the default cobra
|
||||
// pass-through behaviour, otherwise an accidental fall-through in
|
||||
// register.go would silently change every domain's error envelope.
|
||||
func TestRegisterShortcutsLeavesNonMailFlagErrorUntouched(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCmd, _, err := program.Find([]string{"base"})
|
||||
if err != nil {
|
||||
t.Fatalf("find base command: %v", err)
|
||||
}
|
||||
in := errors.New("unknown flag: --bogus")
|
||||
got := baseCmd.FlagErrorFunc()(baseCmd, in)
|
||||
// Default cobra hook is identity — anything else means the mail hook
|
||||
// (which wraps into a typed *errs.ValidationError) leaked across domains.
|
||||
if errs.IsTyped(got) {
|
||||
t.Fatalf("base service unexpectedly produced a typed error: %#v", got)
|
||||
}
|
||||
if got != in {
|
||||
t.Fatalf("base service should pass through original error pointer, got %T (%v)", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
output := os.Getenv("SHORTCUTS_OUTPUT")
|
||||
if output == "" {
|
||||
|
||||
@@ -4,16 +4,12 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// ─── sheets flag ergonomics ─────────────────────────────────────────────
|
||||
@@ -28,106 +24,17 @@ import (
|
||||
// needs no change at all and no other domain's behavior shifts.
|
||||
|
||||
// withFlagErgonomics wraps an optional PostMount so that, after it runs,
|
||||
// the command gets the sheets-specific unknown-flag error (valid flags
|
||||
// inlined) and enum-value normalization (canonical vocabulary auto-applied,
|
||||
// typos suggested).
|
||||
// the command gets enum-value normalization. Unknown-flag handling is owned by
|
||||
// cmd/root.go's single FlagErrorFunc, which retains the sheets inline flag list.
|
||||
func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
||||
chainEnumNormalization(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
|
||||
// It keeps the root behavior (typed error, did-you-mean suggestions, the
|
||||
// offending flag on params) and additionally inlines the full valid-flag
|
||||
// set: hallucinated sheets flags are usually semantic guesses (--cols for
|
||||
// --range) that edit distance can't rank, and a --help round trip costs an
|
||||
// agent a full extra call. One line here lets it re-issue the command
|
||||
// immediately.
|
||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
||||
if !isUnknown {
|
||||
return common.ValidationErrorf("%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if list := inlineFlagList(valid); list != "" {
|
||||
hint = "valid flags: " + list
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean %s? valid flags: %s",
|
||||
strings.Join(suggestions, ", "), list)
|
||||
}
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// unknownFlagFromParseError extracts the offending long-flag name from
|
||||
// cobra's flag-parse error text ("unknown flag: --query" → "query").
|
||||
// Returns ok=false for anything else (missing argument, invalid value,
|
||||
// unknown shorthand) so those stay structured but generic. Mirrors the
|
||||
// root-level parser in cmd; the prefix contract is cobra's English wording.
|
||||
func unknownFlagFromParseError(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names registered on c, sorted.
|
||||
func visibleFlagNames(c *cobra.Command) []string {
|
||||
var names []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Hidden {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// inlineFlagListLimit caps how many flag names ride inline on an
|
||||
// unknown-flag hint. Sheets shortcuts stay well under it.
|
||||
const inlineFlagListLimit = 25
|
||||
|
||||
// inlineFlagList renders valid flag names as one comma-separated line for
|
||||
// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when
|
||||
// there is nothing to list.
|
||||
func inlineFlagList(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
shown := names
|
||||
var suffix string
|
||||
if len(names) > inlineFlagListLimit {
|
||||
shown = names[:inlineFlagListLimit]
|
||||
suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit)
|
||||
}
|
||||
parts := make([]string, len(shown))
|
||||
for i, n := range shown {
|
||||
parts[i] = "--" + n
|
||||
}
|
||||
return strings.Join(parts, ", ") + suffix
|
||||
}
|
||||
|
||||
// ─── enum vocabulary normalization ──────────────────────────────────────
|
||||
|
||||
// enumAliases maps habitual values agents import from CSS / Excel / Google
|
||||
|
||||
@@ -5,7 +5,6 @@ package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -14,122 +13,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagFromParseError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --cols", "cols", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagFromParseError(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets
|
||||
// override of the root unknown-flag error: --cols is a semantic guess for
|
||||
// --range that edit distance can't rank, so the hint must inline the full
|
||||
// valid-flag list instead of deferring to a --help round trip.
|
||||
func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().Int("width", 0, "")
|
||||
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" {
|
||||
t.Errorf("Params = %v, want one entry named --cols", verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "--help") {
|
||||
t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint)
|
||||
}
|
||||
for _, want := range []string{"--range", "--width"} {
|
||||
if !strings.Contains(verr.Hint, want) {
|
||||
t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior
|
||||
// (did-you-mean suggestion, machine-readable Suggestions) is preserved by
|
||||
// the sheets override, with the valid-flag list appended.
|
||||
func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
found := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "--range" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
for _, want := range []string{"did you mean", "--range", "--dry-run"} {
|
||||
if !strings.Contains(verr.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, verr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineFlagList_TruncatesPastLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := inlineFlagList(nil); got != "" {
|
||||
t.Errorf("inlineFlagList(nil) = %q, want empty", got)
|
||||
}
|
||||
names := make([]string, inlineFlagListLimit+5)
|
||||
for i := range names {
|
||||
names[i] = fmt.Sprintf("flag-%02d", i)
|
||||
}
|
||||
got := inlineFlagList(names)
|
||||
if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") {
|
||||
t.Errorf("truncated list should count the overflow and defer to --help, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, names[inlineFlagListLimit]) {
|
||||
t.Errorf("list should stop at the limit, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalEnumValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -278,19 +161,4 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown flag inlines valid flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user