From 4dbbe72a6e5d49960e4cca0203dc48053a64fdc2 Mon Sep 17 00:00:00 2001 From: xuzhuocong Date: Mon, 11 May 2026 03:16:31 +0000 Subject: [PATCH] feat(mail): add unknown-flag fuzzy-match for lark-cli mail domain Adds shortcuts/mail/flag_suggest.go (~120 LOC) implementing a cobra FlagErrorFunc hook for the mail subcommand tree. On 'unknown flag: --X' or 'unknown shorthand flag: "X" in -X', it collects flags from the current command via cmd.Flags().VisitAll, runs bidirectional prefix match + Levenshtein DP (threshold=max(1,len/3+1), cap 4), and returns top-5 candidates inside the existing ErrorEnvelope JSON: error.type = "unknown_flag" error.detail.{unknown, command_path, candidates} error.detail.candidates[*] = {flag, shorthand, distance, reason} Exit code stays 1 (ExitAPI), not ExitValidation - no breaking change for CI/agent scripts that check non-zero exit. stderr switches from plain 'Error: unknown flag: --X' to JSON envelope, aligning with the existing 'errors = JSON envelope on stderr' convention; mail unknown-flag was the last gap. Scope is strictly the mail subcommand tree: shortcuts/register.go gains a single 'if service == "mail" { mail.InstallOnMail(svc) }' branch after the existing Mount loop. Other domains (calendar / im / api / auth / ...) keep cobra's default FlagErrorFunc and unchanged plain-text stderr behavior. Covers: - shortcuts/mail/flag_suggest.go (new, ~120 LOC) - shortcuts/mail/flag_suggest_test.go (new, 12 table-driven tests) - shortcuts/register.go (+3 lines after mail Mount loop) No changes to cmd/root.go or internal/output/* - ErrDetail.Detail is already interface{}, handleRootError already routes *ExitError via WriteErrorEnvelope. --- shortcuts/mail/flag_suggest.go | 288 +++++++++++++++++++++++ shortcuts/mail/flag_suggest_test.go | 352 ++++++++++++++++++++++++++++ shortcuts/register.go | 3 + shortcuts/register_test.go | 61 +++++ 4 files changed, 704 insertions(+) create mode 100644 shortcuts/mail/flag_suggest.go create mode 100644 shortcuts/mail/flag_suggest_test.go diff --git a/shortcuts/mail/flag_suggest.go b/shortcuts/mail/flag_suggest.go new file mode 100644 index 0000000000..15b0765254 --- /dev/null +++ b/shortcuts/mail/flag_suggest.go @@ -0,0 +1,288 @@ +// 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/internal/output" +) + +// 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. It is serialised into the ErrorEnvelope's +// error.detail.candidates[] array. +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 +// structured *output.ExitError 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) + detail := map[string]any{ + "unknown": rawUnknownToken(token, isShorthand), + "command_path": c.CommandPath(), + "candidates": matches, + } + // Code is ExitAPI (=1), matching cobra's default unknown-flag exit + // code. The structured type discrimination lives in error.type. + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "unknown_flag", + Message: err.Error(), + Hint: hint, + Detail: detail, + }, + } +} + +// 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 the ErrorEnvelope. +// 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] +} diff --git a/shortcuts/mail/flag_suggest_test.go b/shortcuts/mail/flag_suggest_test.go new file mode 100644 index 0000000000..82839c73a3 --- /dev/null +++ b/shortcuts/mail/flag_suggest_test.go @@ -0,0 +1,352 @@ +// 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/internal/output" +) + +// --- 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 TestFlagSuggestErrorFunc_LongUnknown_ReturnsExitError(t *testing.T) { + cmd := newFakeMailCmd() + got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos")) + + var exitErr *output.ExitError + require.True(t, errors.As(got, &exitErr), "expected *output.ExitError, got %T", got) + require.NotNil(t, exitErr.Detail) + assert.Equal(t, "unknown_flag", exitErr.Detail.Type) + assert.Equal(t, "unknown flag: --tos", exitErr.Detail.Message) + assert.Contains(t, exitErr.Detail.Hint, "--to") + + detail, ok := exitErr.Detail.Detail.(map[string]any) + require.True(t, ok, "Detail.Detail should be map[string]any") + assert.Equal(t, "--tos", detail["unknown"]) + assert.Equal(t, cmd.CommandPath(), detail["command_path"]) + + cands, ok := detail["candidates"].([]Candidate) + require.True(t, ok, "candidates should be []Candidate") + require.NotEmpty(t, cands) + + var foundTo bool + for _, c := range cands { + if c.Flag == "--to" { + foundTo = true + assert.Equal(t, "prefix", c.Reason) + break + } + } + assert.True(t, foundTo, "expected --to in candidates") +} + +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_ExitCodeIsOne(t *testing.T) { + cmd := newFakeMailCmd() + got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos")) + var exitErr *output.ExitError + require.True(t, errors.As(got, &exitErr)) + // Hard contract — both compile-time and runtime guards: + assert.Equal(t, output.ExitAPI, exitErr.Code, "unknown_flag must use ExitAPI, not ExitValidation") + assert.Equal(t, 1, output.ExitAPI, "ExitAPI constant must remain 1") +} + +// --- 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")) + var exitErr *output.ExitError + require.True(t, errors.As(got, &exitErr), "installed hook must produce *output.ExitError") + assert.Equal(t, "unknown_flag", exitErr.Detail.Type) +} + +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")) + var exitErr *output.ExitError + require.True(t, errors.As(got, &exitErr)) + detail := exitErr.Detail.Detail.(map[string]any) + assert.Equal(t, "--tos", detail["unknown"], "value tail must be stripped before echoing") +} + +func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) { + cmd := newFakeMailCmd() + got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY")) + var exitErr *output.ExitError + require.True(t, errors.As(got, &exitErr)) + detail := exitErr.Detail.Detail.(map[string]any) + assert.Equal(t, "-b", detail["unknown"]) + cands, ok := detail["candidates"].([]Candidate) + require.True(t, ok) + // newFakeMailCmd has --body/-b; exact shorthand hit expected. + require.NotEmpty(t, cands) + assert.Equal(t, "--body", cands[0].Flag) + assert.Equal(t, "b", cands[0].Shorthand) +} + +func TestFlagSuggestErrorFunc_CandidatesAlwaysArray(t *testing.T) { + // A cobra command with no flags forces collectFlags → empty names → + // suggest → nil. The envelope must still expose candidates as a + // non-nil []Candidate so the JSON wire shape is "candidates: []" + // rather than "candidates: null". + bare := &cobra.Command{Use: "mail"} + got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus")) + var exitErr *output.ExitError + require.True(t, errors.As(got, &exitErr)) + detail := exitErr.Detail.Detail.(map[string]any) + cands, ok := detail["candidates"].([]Candidate) + require.True(t, ok, "candidates must be []Candidate even when empty") + assert.NotNil(t, cands, "candidates must be non-nil empty slice, not nil") + assert.Empty(t, cands) +} + +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")) + var exitErr *output.ExitError + require.True(t, errors.As(got, &exitErr)) + assert.Contains(t, exitErr.Detail.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")) +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 120f0415c6..f2e9f85dd5 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -99,5 +99,8 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f for _, shortcut := range shortcuts { shortcut.MountWithContext(ctx, svc, f) } + if service == "mail" { + mail.InstallOnMail(svc) + } } } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index a05e1c839a..ae49b64b39 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -6,6 +6,7 @@ package shortcuts import ( "bytes" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -14,6 +15,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -305,6 +307,65 @@ 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 structured *output.ExitError with type "unknown_flag". + got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus")) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T (%v)", got, got) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "unknown_flag" { + t.Fatalf("expected Detail.Type=unknown_flag, got %#v", exitErr.Detail) + } + if exitErr.Code != output.ExitAPI { + t.Fatalf("expected Code=ExitAPI(%d), got %d", output.ExitAPI, exitErr.Code) + } +} + +// 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 + // leaked across domains. + var exitErr *output.ExitError + if errors.As(got, &exitErr) { + t.Fatalf("base service unexpectedly produced *output.ExitError: %#v", exitErr) + } + 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 == "" {