Files
larksuite__cli/shortcuts/mail/flag_suggest.go
evandance 5e6a3eb857 feat(mail): return typed error envelopes across the mail domain (#1250)
* feat(mail): return typed error envelopes across the mail domain

Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text.

- Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable.

- API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved.

- Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal.

- Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls.

- Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.

* fix(mail): report aborted draft-send batches as a single failure result

When an account-level failure interrupts a batch send after some drafts
already went out, the command previously produced two machine-readable
failure results: the partial-failure ledger on stdout and a second error
envelope on stderr. Consumers could not tell which one to recover from.

The batch ledger is now the only failure result for that case: it gains
aborted and abort_error fields carrying the typed cause, so callers can
see which drafts were sent, which failed, why the batch stopped, and how
to recover — all from stdout. A --stop-on-error stop keeps these fields
unset because stopping early there is the caller's own choice.
2026-06-04 21:02:20 +08:00

287 lines
8.5 KiB
Go

// 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 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]
}