219 lines
7.8 KiB
Go
219 lines
7.8 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package corecmd
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// GroupMode declares whether a command with children is navigation-only or
|
|
// also owns business execution. The zero value means the command is a leaf.
|
|
type GroupMode string
|
|
|
|
const (
|
|
// GroupNavigationOnly is a parent whose own invocation only renders help.
|
|
GroupNavigationOnly GroupMode = "navigation_only"
|
|
// GroupHybrid is a runnable business command that also owns children.
|
|
GroupHybrid GroupMode = "hybrid"
|
|
)
|
|
|
|
// PositionalsPolicy declares whether a group may consume positional values.
|
|
type PositionalsPolicy string
|
|
|
|
const (
|
|
// PositionalsReject makes every unmatched positional token eligible for
|
|
// command-resolution recovery rather than business execution.
|
|
PositionalsReject PositionalsPolicy = "reject"
|
|
// PositionalsAllow reserves positional values for the group's business
|
|
// execution. Recovery must therefore be disabled to avoid ambiguity.
|
|
PositionalsAllow PositionalsPolicy = "allow"
|
|
)
|
|
|
|
// RecoveryPolicy declares the search scope for unknown-command recovery.
|
|
type RecoveryPolicy string
|
|
|
|
const (
|
|
// RecoverySibling suggests only direct children of the current group.
|
|
RecoverySibling RecoveryPolicy = "sibling"
|
|
// RecoveryDeep may search all descendants of the current group.
|
|
RecoveryDeep RecoveryPolicy = "deep"
|
|
// RecoveryDisabled leaves positional handling entirely to Cobra or the
|
|
// command's business execution.
|
|
RecoveryDisabled RecoveryPolicy = "disabled"
|
|
)
|
|
|
|
// GroupPolicy is the typed declaration for every non-leaf command.
|
|
//
|
|
// Its zero value deliberately means "leaf": callers must declare all three
|
|
// fields together for a group. ApplyGroupPolicy compiles the declaration to
|
|
// Cobra behavior and private framework metadata; command authors must not
|
|
// author parallel kind annotations themselves.
|
|
type GroupPolicy struct {
|
|
Mode GroupMode
|
|
Positionals PositionalsPolicy
|
|
Recovery RecoveryPolicy
|
|
}
|
|
|
|
const groupPolicyAnnotation = "dws.internal.corecmd.group_policy.v1"
|
|
|
|
// IsZero reports whether p is the leaf declaration.
|
|
func (p GroupPolicy) IsZero() bool {
|
|
return p.Mode == "" && p.Positionals == "" && p.Recovery == ""
|
|
}
|
|
|
|
// ValidateGroupPolicy rejects partial declarations, unknown enum values, and
|
|
// combinations whose parsing semantics would be ambiguous.
|
|
func ValidateGroupPolicy(p GroupPolicy) error {
|
|
if p.IsZero() {
|
|
return nil
|
|
}
|
|
switch p.Mode {
|
|
case GroupNavigationOnly, GroupHybrid:
|
|
default:
|
|
return fmt.Errorf("invalid group mode %q", p.Mode)
|
|
}
|
|
switch p.Positionals {
|
|
case PositionalsReject, PositionalsAllow:
|
|
default:
|
|
return fmt.Errorf("invalid group positionals policy %q", p.Positionals)
|
|
}
|
|
switch p.Recovery {
|
|
case RecoverySibling, RecoveryDeep, RecoveryDisabled:
|
|
default:
|
|
return fmt.Errorf("invalid group recovery policy %q", p.Recovery)
|
|
}
|
|
if p.Mode == GroupNavigationOnly && p.Positionals != PositionalsReject {
|
|
return fmt.Errorf("navigation-only group requires positionals=%q", PositionalsReject)
|
|
}
|
|
if p.Positionals == PositionalsAllow && p.Recovery != RecoveryDisabled {
|
|
return fmt.Errorf("group with positionals=%q requires recovery=%q", PositionalsAllow, RecoveryDisabled)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApplyGroupPolicy is the sole declaration API for non-leaf command behavior.
|
|
// Invalid declarations panic because they are framework construction bugs,
|
|
// matching the fail-closed behavior of Spec flag/constraint declarations.
|
|
//
|
|
// Navigation-only groups receive the shared help/unknown-command RunE. Hybrid
|
|
// groups retain their existing RunE; when they reject positionals, a wrapper
|
|
// sends non-empty args to the same unknown-command resolver before invoking
|
|
// business execution. When recovery is enabled, rejecting positionals
|
|
// deliberately compiles to cobra.ArbitraryArgs: Cobra must not intercept the
|
|
// token with a generic error before command resolution can produce bounded
|
|
// guidance. RecoveryDisabled instead compiles rejection to cobra.NoArgs.
|
|
func ApplyGroupPolicy(cmd *cobra.Command, policy GroupPolicy) {
|
|
if cmd == nil {
|
|
panic("cannot apply GroupPolicy to a nil command")
|
|
}
|
|
if policy.IsZero() {
|
|
panic(fmt.Sprintf("command %q cannot apply the zero GroupPolicy; zero means leaf", cmd.Name()))
|
|
}
|
|
if err := ValidateGroupPolicy(policy); err != nil {
|
|
panic(fmt.Sprintf("command %q declares invalid GroupPolicy: %v", cmd.Name(), err))
|
|
}
|
|
if existing, ok, err := GroupPolicyFor(cmd); err != nil {
|
|
panic(fmt.Sprintf("command %q carries invalid GroupPolicy metadata: %v", cmd.Name(), err))
|
|
} else if ok && existing != policy {
|
|
panic(fmt.Sprintf("command %q redeclares GroupPolicy from %+v to %+v", cmd.Name(), existing, policy))
|
|
} else if ok {
|
|
return
|
|
}
|
|
|
|
if policy.Mode == GroupNavigationOnly {
|
|
cmd.Run = nil
|
|
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
|
return runGroupPolicy(cmd, args, policy)
|
|
}
|
|
} else if cmd.RunE == nil {
|
|
panic(fmt.Sprintf("hybrid group %q must declare RunE before GroupPolicy is applied", cmd.Name()))
|
|
} else if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
|
|
businessRunE := cmd.RunE
|
|
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
|
if len(args) > 0 {
|
|
return runGroupPolicy(cmd, args, policy)
|
|
}
|
|
return businessRunE(cmd, args)
|
|
}
|
|
}
|
|
if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
|
|
cmd.Args = cobra.ArbitraryArgs
|
|
} else if policy.Positionals == PositionalsReject {
|
|
cmd.Args = cobra.NoArgs
|
|
}
|
|
|
|
if cmd.Annotations == nil {
|
|
cmd.Annotations = map[string]string{}
|
|
}
|
|
cmd.Annotations[groupPolicyAnnotation] = encodeGroupPolicy(policy)
|
|
}
|
|
|
|
// GroupPolicyFor reads the typed declaration compiled onto cmd. The boolean is
|
|
// false only for a leaf. Malformed private metadata is returned as an error so
|
|
// tree assembly can fail closed instead of silently treating a group as a leaf.
|
|
func GroupPolicyFor(cmd *cobra.Command) (GroupPolicy, bool, error) {
|
|
if cmd == nil || cmd.Annotations == nil {
|
|
return GroupPolicy{}, false, nil
|
|
}
|
|
raw, ok := cmd.Annotations[groupPolicyAnnotation]
|
|
if !ok {
|
|
return GroupPolicy{}, false, nil
|
|
}
|
|
parts := strings.Split(raw, "|")
|
|
if len(parts) != 3 {
|
|
return GroupPolicy{}, false, fmt.Errorf("malformed encoded GroupPolicy %q", raw)
|
|
}
|
|
policy := GroupPolicy{
|
|
Mode: GroupMode(parts[0]),
|
|
Positionals: PositionalsPolicy(parts[1]),
|
|
Recovery: RecoveryPolicy(parts[2]),
|
|
}
|
|
if err := ValidateGroupPolicy(policy); err != nil {
|
|
return GroupPolicy{}, false, err
|
|
}
|
|
if policy.IsZero() {
|
|
return GroupPolicy{}, false, fmt.Errorf("encoded GroupPolicy must not be zero")
|
|
}
|
|
return policy, true, nil
|
|
}
|
|
|
|
func encodeGroupPolicy(policy GroupPolicy) string {
|
|
return string(policy.Mode) + "|" + string(policy.Positionals) + "|" + string(policy.Recovery)
|
|
}
|
|
|
|
func runGroupPolicy(cmd *cobra.Command, args []string, policy GroupPolicy) error {
|
|
if len(args) == 0 {
|
|
return cmd.Help()
|
|
}
|
|
input := strings.TrimSpace(args[0])
|
|
reason := cmdutil.ClassifyCommandResolution(cmd, input)
|
|
suggestions := cmdutil.SuggestSubcommands(cmd, input)
|
|
if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == RecoveryDeep {
|
|
if deep := cmdutil.SuggestDescendantSubcommands(cmd, input); len(deep) > 0 {
|
|
suggestions = deep
|
|
}
|
|
}
|
|
return cmdutil.NewCommandResolution(
|
|
cmd,
|
|
input,
|
|
reason,
|
|
suggestions,
|
|
"",
|
|
).Err()
|
|
}
|