Files
dingtalk-workspace-cli/pkg/cmdutil/command_resolution.go

223 lines
6.8 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cmdutil
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// ResolutionReason is the stable machine-readable classification for a
// command-token resolution failure.
type ResolutionReason string
const (
// ResolutionUnknownSubcommand reports a token that does not resolve to a
// child command of the selected parent.
ResolutionUnknownSubcommand ResolutionReason = "unknown_subcommand"
// ResolutionUnknownShortcut reports an explicit +shortcut token that does
// not resolve under a top-level service.
ResolutionUnknownShortcut ResolutionReason = "unknown_shortcut"
)
// CommandResolution is the immutable typed projection of a command-token
// resolution failure. Its constructors normalize suggestions once so the
// human hint and machine-readable details cannot diverge.
type CommandResolution struct {
reason ResolutionReason
input string
suggestions []string
message string
hint string
actions []string
}
// ClassifyCommandResolution returns the stable reason for an unresolved token.
// A leading '+' is shortcut syntax only for a top-level service that actually
// exposes +shortcut children, and only when it does not already name a real
// child or alias. Keeping this classification beside CommandResolution
// prevents PreParse and direct Cobra execution from producing different
// machine contracts for the same input.
func ClassifyCommandResolution(parent *cobra.Command, input string) ResolutionReason {
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "+") && isTopLevelShortcutService(parent) && !hasExactChildCommand(parent, input) {
return ResolutionUnknownShortcut
}
return ResolutionUnknownSubcommand
}
// NewCommandResolution builds a bounded command-resolution result. Callers
// may pass sibling suggestions from SuggestSubcommands or reviewed deep-path
// candidates; both are normalized to the same three-item contract.
func NewCommandResolution(parent *cobra.Command, input string, reason ResolutionReason, suggestions []string, authoredHint string) CommandResolution {
input = strings.TrimSpace(input)
suggestions = normalizeCommandSuggestions(suggestions)
parentPath := commandResolutionParentPath(parent)
helpAction := fmt.Sprintf("Run '%s --help' for the full list", parentPath)
resolution := CommandResolution{
reason: reason,
input: input,
suggestions: suggestions,
actions: []string{helpAction},
}
switch reason {
case ResolutionUnknownShortcut:
resolution.message = fmt.Sprintf("unknown shortcut %q for %q", input, parentPath)
if parent != nil {
root := parent.Root()
if root != nil {
resolution.actions = append(resolution.actions,
fmt.Sprintf("Run '%s shortcut list --service %s --format json'", root.Name(), parent.Name()))
}
}
default:
resolution.reason = ResolutionUnknownSubcommand
resolution.message = fmt.Sprintf("unknown subcommand %q for %q", input, parentPath)
}
authoredHint = strings.TrimSpace(authoredHint)
if authoredHint != "" {
resolution.hint = authoredHint + " (" + helpAction + ")"
} else {
resolution.hint = FormatSubcommandSuggestionHint(parent, suggestions, helpAction)
}
return resolution
}
// NewInputCommandResolution classifies input and builds its one normalized
// human/machine projection. It is the shared entry point for PreParse and
// direct group execution.
func NewInputCommandResolution(parent *cobra.Command, input string, suggestions []string) CommandResolution {
return NewCommandResolution(parent, input, ClassifyCommandResolution(parent, input), suggestions, "")
}
// Details returns a fresh machine-readable payload for the resolution. It
// always carries the exact bounded suggestions rendered in Hint.
func (r CommandResolution) Details() map[string]any {
suggestions := make([]string, len(r.suggestions))
copy(suggestions, r.suggestions)
return map[string]any{
"input": r.input,
"suggestions": suggestions,
}
}
// Err projects the resolution through the repository's structured validation
// error contract.
func (r CommandResolution) Err() error {
return apperrors.NewValidation(
r.message,
apperrors.WithReason(string(r.reason)),
apperrors.WithHint(r.hint),
apperrors.WithActions(r.actions...),
apperrors.WithDetails(r.Details()),
)
}
// GroupRunE is the reusable handler for navigation-only parent commands. It
// shows help without arguments and otherwise returns the same structured
// resolution contract used by pre-parse validation.
func GroupRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
input := strings.TrimSpace(args[0])
return NewInputCommandResolution(cmd, input, SuggestSubcommands(cmd, input)).Err()
}
// HintSubCmd creates a hidden compatibility command for a reviewed wrong path.
// It keeps hint-only identity while returning the unified typed resolution.
func HintSubCmd(use, authoredHint string) *cobra.Command {
command := &cobra.Command{
Use: use,
Hidden: true,
Annotations: map[string]string{hintOnlyCommandAnnotation: "true"},
}
command.RunE = func(cmd *cobra.Command, _ []string) error {
parent := cmd.Parent()
if parent == nil {
parent = cmd.Root()
}
return NewCommandResolution(
parent,
cmd.Name(),
ResolutionUnknownSubcommand,
[]string{},
authoredHint,
).Err()
}
return command
}
func normalizeCommandSuggestions(suggestions []string) []string {
out := make([]string, 0, min(len(suggestions), MaxCommandSuggestions))
seen := make(map[string]struct{}, min(len(suggestions), MaxCommandSuggestions))
for _, suggestion := range suggestions {
suggestion = strings.TrimSpace(suggestion)
if suggestion == "" {
continue
}
if _, duplicate := seen[suggestion]; duplicate {
continue
}
seen[suggestion] = struct{}{}
out = append(out, suggestion)
if len(out) == MaxCommandSuggestions {
break
}
}
return out
}
func commandResolutionParentPath(parent *cobra.Command) string {
if parent == nil {
return "dws"
}
if path := strings.TrimSpace(parent.CommandPath()); path != "" {
return path
}
if name := strings.TrimSpace(parent.Name()); name != "" {
return name
}
return "dws"
}
func hasExactChildCommand(parent *cobra.Command, candidate string) bool {
if parent == nil {
return false
}
for _, child := range parent.Commands() {
if child.Name() == candidate {
return true
}
for _, alias := range child.Aliases {
if alias == candidate {
return true
}
}
}
return false
}
func isTopLevelShortcutService(parent *cobra.Command) bool {
if parent == nil {
return false
}
root := parent.Root()
if root == nil || parent == root || parent.Parent() != root {
return false
}
for _, child := range parent.Commands() {
if strings.HasPrefix(child.Name(), "+") {
return true
}
}
return false
}