feat: add pipeline engine for pre-parse and post-parse input correction
- AliasHandler: normalise model-generated flag casing (--userId → --user-id) - StickyHandler: split glued flag values (--limit100 → --limit 100) - ParamNameHandler: fix near-miss flag typos (--limt → --limit) - ParamValueHandler: normalise structured parameter values after parsing
This commit is contained in:
1 parent
4a4633d188
commit
f75227aad1
14 files changed
+2665
No files matched your search
@@ -0,0 +1,146 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// AliasHandler normalises flag names in raw argv so that common
|
||||
// model-generated variants resolve to the canonical kebab-case name.
|
||||
//
|
||||
// Supported normalisations:
|
||||
// - camelCase → kebab-case (--userId → --user-id)
|
||||
// - snake_case → kebab-case (--user_name → --user-name)
|
||||
// - UPPER-CASE → lower-case (--USER-ID → --user-id)
|
||||
// - PascalCase → kebab-case (--UserName → --user-name)
|
||||
//
|
||||
// The handler only rewrites tokens that start with "--" and whose
|
||||
// normalised form matches a known flag name. Unknown flags are left
|
||||
// untouched so that Cobra can report them as errors.
|
||||
type AliasHandler struct{}
|
||||
|
||||
func (AliasHandler) Name() string { return "alias" }
|
||||
func (AliasHandler) Phase() pipeline.Phase { return pipeline.PreParse }
|
||||
|
||||
func (AliasHandler) Handle(ctx *pipeline.Context) error {
|
||||
if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
known := buildFlagSet(ctx.FlagSpecs)
|
||||
result := make([]string, 0, len(ctx.Args))
|
||||
|
||||
for i, arg := range ctx.Args {
|
||||
rewritten, ok := tryNormaliseFlag(arg, known)
|
||||
if ok {
|
||||
ctx.AddCorrection("alias", pipeline.PreParse, rewritten, arg, rewritten, "alias")
|
||||
result = append(result, rewritten)
|
||||
} else {
|
||||
result = append(result, ctx.Args[i])
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Args = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryNormaliseFlag checks whether arg is a "--flag" token that can be
|
||||
// normalised to a known flag name. It handles both bare flags and
|
||||
// "--flag=value" syntax.
|
||||
func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) {
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
bare := arg[2:]
|
||||
if bare == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Handle --flag=value syntax: split, normalise the key, reassemble.
|
||||
var suffix string
|
||||
if idx := strings.IndexByte(bare, '='); idx >= 0 {
|
||||
suffix = bare[idx:] // includes "="
|
||||
bare = bare[:idx]
|
||||
}
|
||||
|
||||
// Already a known flag in its current form — no change needed.
|
||||
if known[bare] {
|
||||
return "", false
|
||||
}
|
||||
|
||||
normalised := toKebabCase(bare)
|
||||
if normalised == bare {
|
||||
return "", false
|
||||
}
|
||||
if !known[normalised] {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return "--" + normalised + suffix, true
|
||||
}
|
||||
|
||||
// toKebabCase converts a string from camelCase, PascalCase, or
|
||||
// snake_case to kebab-case. Examples:
|
||||
//
|
||||
// "userId" → "user-id"
|
||||
// "UserName" → "user-name"
|
||||
// "user_name" → "user-name"
|
||||
// "USER_ID" → "user-id"
|
||||
// "pageSize" → "page-size"
|
||||
func toKebabCase(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 4) // small extra for hyphens
|
||||
|
||||
runes := []rune(s)
|
||||
for i, r := range runes {
|
||||
if r == '_' || r == ' ' {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if unicode.IsUpper(r) {
|
||||
// Insert hyphen before an uppercase letter when:
|
||||
// 1. Not at start, AND
|
||||
// 2. Previous char was lowercase, OR
|
||||
// 3. Next char is lowercase (handles "userID" → "user-id"
|
||||
// at the boundary between "I" and "D" in "ID" we don't
|
||||
// split, but "IDs" → we split before "s" which is
|
||||
// handled by the lowercase check at the next iteration).
|
||||
if i > 0 {
|
||||
prev := runes[i-1]
|
||||
if unicode.IsLower(prev) {
|
||||
b.WriteByte('-')
|
||||
} else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
} else {
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func TestToKebabCase(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"userId", "user-id"},
|
||||
{"UserName", "user-name"},
|
||||
{"user_name", "user-name"},
|
||||
{"USER_ID", "user-id"},
|
||||
{"pageSize", "page-size"},
|
||||
{"user-id", "user-id"},
|
||||
{"limit", "limit"},
|
||||
{"ID", "id"},
|
||||
{"userID", "user-id"},
|
||||
{"HTMLParser", "html-parser"},
|
||||
{"getHTTPResponse", "get-http-response"},
|
||||
{"a", "a"},
|
||||
{"", ""},
|
||||
{"ABC", "abc"},
|
||||
{"already-kebab", "already-kebab"},
|
||||
{"with spaces", "with-spaces"},
|
||||
{"__leading_underscores", "leading-underscores"},
|
||||
{"mixedCamel_and_snake", "mixed-camel-and-snake"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := toKebabCase(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("toKebabCase(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
flags []string
|
||||
want string
|
||||
corrections int
|
||||
}{
|
||||
{
|
||||
name: "camelCase to kebab-case",
|
||||
args: []string{"--userId", "123"},
|
||||
flags: []string{"user-id"},
|
||||
want: "--user-id 123",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "snake_case to kebab-case",
|
||||
args: []string{"--user_name", "alice"},
|
||||
flags: []string{"user-name"},
|
||||
want: "--user-name alice",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "UPPER to lower",
|
||||
args: []string{"--USER-ID", "456"},
|
||||
flags: []string{"user-id"},
|
||||
want: "--user-id 456",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "PascalCase to kebab-case",
|
||||
args: []string{"--PageSize", "20"},
|
||||
flags: []string{"page-size"},
|
||||
want: "--page-size 20",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "already correct — no change",
|
||||
args: []string{"--user-id", "789"},
|
||||
flags: []string{"user-id"},
|
||||
want: "--user-id 789",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "unknown flag — left untouched",
|
||||
args: []string{"--fooBar", "val"},
|
||||
flags: []string{"user-id"},
|
||||
want: "--fooBar val",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "with = syntax",
|
||||
args: []string{"--userId=123"},
|
||||
flags: []string{"user-id"},
|
||||
want: "--user-id=123",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple flags mixed",
|
||||
args: []string{"--userId", "1", "--pageSize", "10", "--name", "test"},
|
||||
flags: []string{"user-id", "page-size", "name"},
|
||||
want: "--user-id 1 --page-size 10 --name test",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "single dash is ignored",
|
||||
args: []string{"-v"},
|
||||
flags: []string{"verbose"},
|
||||
want: "-v",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "empty args",
|
||||
args: []string{},
|
||||
flags: []string{"limit"},
|
||||
want: "",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "bare double dash",
|
||||
args: []string{"--"},
|
||||
flags: []string{"limit"},
|
||||
want: "--",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "normalised form not in known set",
|
||||
args: []string{"--unknownFlag"},
|
||||
flags: []string{"limit"},
|
||||
want: "--unknownFlag",
|
||||
corrections: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string{}, tt.args...),
|
||||
FlagSpecs: flagSpecs(tt.flags...),
|
||||
}
|
||||
h := AliasHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle error: %v", err)
|
||||
}
|
||||
got := strings.Join(ctx.Args, " ")
|
||||
if got != tt.want {
|
||||
t.Errorf("Args = %q, want %q", got, tt.want)
|
||||
}
|
||||
if len(ctx.Corrections) != tt.corrections {
|
||||
t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections)
|
||||
}
|
||||
for _, c := range ctx.Corrections {
|
||||
if c.Kind != "alias" {
|
||||
t.Errorf("correction kind = %q, want %q", c.Kind, "alias")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasHandlerNameAndPhase(t *testing.T) {
|
||||
h := AliasHandler{}
|
||||
if h.Name() != "alias" {
|
||||
t.Errorf("Name() = %q, want %q", h.Name(), "alias")
|
||||
}
|
||||
if h.Phase() != pipeline.PreParse {
|
||||
t.Errorf("Phase() = %v, want PreParse", h.Phase())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// TestFullPreParsePipeline exercises the complete PreParse handler
|
||||
// chain: AliasHandler → StickyHandler → ParamNameHandler. It
|
||||
// simulates a model-generated CLI invocation with multiple errors
|
||||
// and verifies the pipeline corrects all of them in one pass.
|
||||
func TestFullPreParsePipeline(t *testing.T) {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.RegisterAll(
|
||||
AliasHandler{},
|
||||
StickyHandler{},
|
||||
ParamNameHandler{},
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
flags []string
|
||||
want string
|
||||
corrections int
|
||||
}{
|
||||
{
|
||||
name: "camelCase + sticky combined",
|
||||
args: []string{"--userId", "123", "--pageSize50"},
|
||||
flags: []string{"user-id", "page-size"},
|
||||
want: "--user-id 123 --page-size 50",
|
||||
corrections: 2, // alias(userId) + sticky(pageSize50)
|
||||
},
|
||||
{
|
||||
name: "camelCase + typo combined",
|
||||
args: []string{"--userId", "123", "--limt", "10"},
|
||||
flags: []string{"user-id", "limit"},
|
||||
want: "--user-id 123 --limit 10",
|
||||
corrections: 2, // alias(userId) + fuzzy(limt)
|
||||
},
|
||||
{
|
||||
name: "triple error: case + sticky + typo",
|
||||
args: []string{"--UserName", "alice", "--limit100", "--offse", "0"},
|
||||
flags: []string{"user-name", "limit", "offset"},
|
||||
want: "--user-name alice --limit 100 --offset 0",
|
||||
corrections: 3,
|
||||
},
|
||||
{
|
||||
name: "snake_case + sticky",
|
||||
args: []string{"--user_id", "42", "--pageSize20"},
|
||||
flags: []string{"user-id", "page-size"},
|
||||
want: "--user-id 42 --page-size 20",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "all correct — zero corrections",
|
||||
args: []string{"--user-id", "123", "--limit", "10"},
|
||||
flags: []string{"user-id", "limit"},
|
||||
want: "--user-id 123 --limit 10",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "UPPER case flags",
|
||||
args: []string{"--USER-ID", "999"},
|
||||
flags: []string{"user-id"},
|
||||
want: "--user-id 999",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "= syntax with camelCase",
|
||||
args: []string{"--userId=123", "--pageSize=50"},
|
||||
flags: []string{"user-id", "page-size"},
|
||||
want: "--user-id=123 --page-size=50",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "camelCase sticky split with normalisation",
|
||||
args: []string{"--limitValue100"},
|
||||
flags: []string{"limit-value"},
|
||||
want: "--limit-value 100",
|
||||
corrections: 1, // sticky handles both kebab-normalisation and split
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string{}, tt.args...),
|
||||
FlagSpecs: flagSpecs(tt.flags...),
|
||||
}
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("RunPhase error: %v", err)
|
||||
}
|
||||
got := strings.Join(ctx.Args, " ")
|
||||
if got != tt.want {
|
||||
t.Errorf("Args = %q, want %q", got, tt.want)
|
||||
}
|
||||
if len(ctx.Corrections) != tt.corrections {
|
||||
t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections)
|
||||
for _, c := range ctx.Corrections {
|
||||
t.Logf(" %s/%s: %q → %q", c.Handler, c.Kind, c.Original, c.Corrected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullPostParsePipeline exercises the PostParse handler chain
|
||||
// with the ParamValueHandler normalising multiple value types in a
|
||||
// single invocation.
|
||||
func TestFullPostParsePipeline(t *testing.T) {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.RegisterAll(ParamValueHandler{})
|
||||
|
||||
ctx := &pipeline.Context{
|
||||
Command: "aitable.list_records",
|
||||
Params: map[string]any{
|
||||
"verbose": "yes",
|
||||
"limit": "1,000",
|
||||
"status": "ACTIVE",
|
||||
"created_at": "2024/03/29",
|
||||
"name": "test",
|
||||
},
|
||||
Schema: map[string]any{
|
||||
"properties": map[string]any{
|
||||
"verbose": map[string]any{"type": "boolean"},
|
||||
"limit": map[string]any{"type": "integer"},
|
||||
"status": map[string]any{"type": "string", "enum": []any{"active", "inactive"}},
|
||||
"created_at": map[string]any{"type": "string", "format": "date"},
|
||||
"name": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil {
|
||||
t.Fatalf("RunPhase error: %v", err)
|
||||
}
|
||||
|
||||
if got := ctx.Params["verbose"]; got != true {
|
||||
t.Errorf("verbose = %v, want true", got)
|
||||
}
|
||||
if got := ctx.Params["limit"]; got != int64(1000) {
|
||||
t.Errorf("limit = %v, want 1000", got)
|
||||
}
|
||||
if got := ctx.Params["status"]; got != "active" {
|
||||
t.Errorf("status = %q, want %q", got, "active")
|
||||
}
|
||||
if got := ctx.Params["created_at"]; got != "2024-03-29" {
|
||||
t.Errorf("created_at = %q, want %q", got, "2024-03-29")
|
||||
}
|
||||
// "name" is a plain string — should be untouched.
|
||||
if got := ctx.Params["name"]; got != "test" {
|
||||
t.Errorf("name = %q, want %q", got, "test")
|
||||
}
|
||||
if len(ctx.Corrections) != 4 {
|
||||
t.Errorf("Corrections = %d, want 4", len(ctx.Corrections))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullPipelineEndToEnd exercises all phases in sequence:
|
||||
// PreParse → PostParse, simulating a model invocation that has
|
||||
// errors at both the argv level and the value level.
|
||||
func TestFullPipelineEndToEnd(t *testing.T) {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.RegisterAll(
|
||||
AliasHandler{},
|
||||
StickyHandler{},
|
||||
ParamNameHandler{},
|
||||
ParamValueHandler{},
|
||||
)
|
||||
|
||||
// Phase 1: PreParse — fix raw argv.
|
||||
ctx := &pipeline.Context{
|
||||
Args: []string{
|
||||
"--userId", "u001",
|
||||
"--pageSize50",
|
||||
"--verbosetrue",
|
||||
},
|
||||
FlagSpecs: flagSpecs("user-id", "page-size", "verbose"),
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("PreParse error: %v", err)
|
||||
}
|
||||
|
||||
want := "--user-id u001 --page-size 50 --verbose true"
|
||||
got := strings.Join(ctx.Args, " ")
|
||||
if got != want {
|
||||
t.Errorf("after PreParse: Args = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
preParseCorrections := len(ctx.Corrections)
|
||||
if preParseCorrections != 3 {
|
||||
t.Errorf("PreParse corrections = %d, want 3", preParseCorrections)
|
||||
}
|
||||
|
||||
// Phase 2: PostParse — simulate Cobra having parsed the corrected
|
||||
// args into structured params, then normalise values.
|
||||
ctx.Command = "chat.send_message"
|
||||
ctx.Params = map[string]any{
|
||||
"user_id": "u001",
|
||||
"page_size": "50",
|
||||
"verbose": "true",
|
||||
}
|
||||
ctx.Schema = map[string]any{
|
||||
"properties": map[string]any{
|
||||
"user_id": map[string]any{"type": "string"},
|
||||
"page_size": map[string]any{"type": "integer"},
|
||||
"verbose": map[string]any{"type": "boolean"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil {
|
||||
t.Fatalf("PostParse error: %v", err)
|
||||
}
|
||||
|
||||
if got := ctx.Params["verbose"]; got != true {
|
||||
t.Errorf("verbose = %v (%T), want true (bool)", got, got)
|
||||
}
|
||||
// page_size "50" has no grouping separators — unchanged.
|
||||
if got := ctx.Params["page_size"]; got != "50" {
|
||||
t.Errorf("page_size = %v, want %q", got, "50")
|
||||
}
|
||||
|
||||
totalCorrections := len(ctx.Corrections)
|
||||
postParseCorrections := totalCorrections - preParseCorrections
|
||||
if postParseCorrections != 1 {
|
||||
t.Errorf("PostParse corrections = %d, want 1", postParseCorrections)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreParseDoesNotBreakValidArgs verifies that valid, correctly
|
||||
// formatted args pass through the pipeline without modification.
|
||||
func TestPreParseDoesNotBreakValidArgs(t *testing.T) {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.RegisterAll(
|
||||
AliasHandler{},
|
||||
StickyHandler{},
|
||||
ParamNameHandler{},
|
||||
)
|
||||
|
||||
original := []string{
|
||||
"--user-id", "123",
|
||||
"--limit", "10",
|
||||
"--offset", "0",
|
||||
"--format", "json",
|
||||
"--verbose",
|
||||
}
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string{}, original...),
|
||||
FlagSpecs: flagSpecs("user-id", "limit", "offset", "format", "verbose"),
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("RunPhase error: %v", err)
|
||||
}
|
||||
|
||||
got := strings.Join(ctx.Args, " ")
|
||||
want := strings.Join(original, " ")
|
||||
if got != want {
|
||||
t.Errorf("Args changed!\n got: %q\n want: %q", got, want)
|
||||
}
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Errorf("unexpected corrections: %d", len(ctx.Corrections))
|
||||
for _, c := range ctx.Corrections {
|
||||
t.Logf(" %s: %q → %q", c.Handler, c.Original, c.Corrected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// ParamNameHandler performs fuzzy correction on flag names that are
|
||||
// not recognised after alias normalisation. It uses Levenshtein edit
|
||||
// distance to find the closest known flag name, with a conservative
|
||||
// threshold to avoid false positives.
|
||||
//
|
||||
// Correction rules:
|
||||
// - Edit distance must be ≤ maxEditDistance (default 2).
|
||||
// - The match must be unambiguous (exactly one candidate within threshold).
|
||||
// - Very short flag names (≤ 3 chars) use a tighter threshold of 1.
|
||||
//
|
||||
// This handler should run after AliasHandler in the PreParse phase
|
||||
// so that obvious normalisation (camelCase → kebab-case) is already
|
||||
// done and fuzzy matching only handles genuine near-misses.
|
||||
type ParamNameHandler struct{}
|
||||
|
||||
const maxEditDistance = 2
|
||||
|
||||
func (ParamNameHandler) Name() string { return "paramname" }
|
||||
func (ParamNameHandler) Phase() pipeline.Phase { return pipeline.PreParse }
|
||||
|
||||
func (ParamNameHandler) Handle(ctx *pipeline.Context) error {
|
||||
if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
known := buildFlagSet(ctx.FlagSpecs)
|
||||
names := make([]string, 0, len(ctx.FlagSpecs))
|
||||
for _, spec := range ctx.FlagSpecs {
|
||||
if spec.Name != "" {
|
||||
names = append(names, spec.Name)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(ctx.Args))
|
||||
for _, arg := range ctx.Args {
|
||||
rewritten, ok := tryFuzzyMatch(arg, known, names)
|
||||
if ok {
|
||||
ctx.AddCorrection("paramname", pipeline.PreParse, rewritten, arg, rewritten, "fuzzy")
|
||||
result = append(result, rewritten)
|
||||
} else {
|
||||
result = append(result, arg)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Args = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryFuzzyMatch attempts to correct an unrecognised "--flag" token by
|
||||
// finding the closest known flag name within the edit distance threshold.
|
||||
func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (string, bool) {
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
bare := arg[2:]
|
||||
if bare == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Handle --flag=value syntax.
|
||||
var suffix string
|
||||
if idx := strings.IndexByte(bare, '='); idx >= 0 {
|
||||
suffix = bare[idx:]
|
||||
bare = bare[:idx]
|
||||
}
|
||||
|
||||
// Already known — nothing to fix.
|
||||
if known[bare] {
|
||||
return "", false
|
||||
}
|
||||
|
||||
threshold := maxEditDistance
|
||||
if len(bare) <= 3 {
|
||||
threshold = 1
|
||||
}
|
||||
|
||||
bestDist := threshold + 1
|
||||
bestMatch := ""
|
||||
ambiguous := false
|
||||
|
||||
for _, candidate := range candidates {
|
||||
dist := levenshtein(bare, candidate)
|
||||
if dist < bestDist {
|
||||
bestDist = dist
|
||||
bestMatch = candidate
|
||||
ambiguous = false
|
||||
} else if dist == bestDist && candidate != bestMatch {
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
|
||||
if bestDist > threshold || ambiguous || bestMatch == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return "--" + bestMatch + suffix, true
|
||||
}
|
||||
|
||||
// levenshtein computes the edit distance between two strings using
|
||||
// the standard dynamic programming approach with O(min(m,n)) space.
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
|
||||
// Ensure a is the shorter string for O(min) space.
|
||||
if la > lb {
|
||||
a, b = b, a
|
||||
la, lb = lb, la
|
||||
}
|
||||
|
||||
prev := make([]int, la+1)
|
||||
curr := make([]int, la+1)
|
||||
for i := range prev {
|
||||
prev[i] = i
|
||||
}
|
||||
|
||||
for j := 1; j <= lb; j++ {
|
||||
curr[0] = j
|
||||
for i := 1; i <= la; i++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[i] = min(
|
||||
prev[i]+1, // deletion
|
||||
curr[i-1]+1, // insertion
|
||||
prev[i-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[la]
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "a", 1},
|
||||
{"abc", "abc", 0},
|
||||
{"abc", "abd", 1},
|
||||
{"abc", "abcd", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"limit", "limt", 1},
|
||||
{"limit", "limti", 2},
|
||||
{"user-id", "user-di", 2},
|
||||
{"page", "pge", 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.a+"→"+tt.b, func(t *testing.T) {
|
||||
got := levenshtein(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
// Verify symmetry.
|
||||
gotRev := levenshtein(tt.b, tt.a)
|
||||
if gotRev != got {
|
||||
t.Errorf("asymmetric: (%q,%q)=%d but (%q,%q)=%d", tt.a, tt.b, got, tt.b, tt.a, gotRev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamNameHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
flags []string
|
||||
want string
|
||||
corrections int
|
||||
}{
|
||||
{
|
||||
name: "typo correction — limt → limit",
|
||||
args: []string{"--limt", "10"},
|
||||
flags: []string{"limit", "offset", "name"},
|
||||
want: "--limit 10",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "transposition — limti → limit",
|
||||
args: []string{"--limti", "10"},
|
||||
flags: []string{"limit", "offset"},
|
||||
want: "--limit 10",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "known flag unchanged",
|
||||
args: []string{"--limit", "10"},
|
||||
flags: []string{"limit"},
|
||||
want: "--limit 10",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "too far — no correction",
|
||||
args: []string{"--xyz", "10"},
|
||||
flags: []string{"limit"},
|
||||
want: "--xyz 10",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "ambiguous — two equally close",
|
||||
args: []string{"--nae", "val"},
|
||||
flags: []string{"name", "nave"},
|
||||
want: "--nae val",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "short flag tighter threshold",
|
||||
args: []string{"--id", "1"},
|
||||
flags: []string{"id"},
|
||||
want: "--id 1",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "short flag one edit away",
|
||||
args: []string{"--ix", "1"},
|
||||
flags: []string{"id"},
|
||||
want: "--id 1",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "short flag two edits away rejected",
|
||||
args: []string{"--xy", "1"},
|
||||
flags: []string{"id"},
|
||||
want: "--xy 1",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "with = syntax",
|
||||
args: []string{"--limt=10"},
|
||||
flags: []string{"limit"},
|
||||
want: "--limit=10",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "empty args",
|
||||
args: []string{},
|
||||
flags: []string{"limit"},
|
||||
want: "",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "single dash ignored",
|
||||
args: []string{"-l"},
|
||||
flags: []string{"limit"},
|
||||
want: "-l",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "missing char — offse → offset",
|
||||
args: []string{"--offse", "0"},
|
||||
flags: []string{"offset", "limit"},
|
||||
want: "--offset 0",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "extra char — offsett → offset",
|
||||
args: []string{"--offsett", "0"},
|
||||
flags: []string{"offset", "limit"},
|
||||
want: "--offset 0",
|
||||
corrections: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string{}, tt.args...),
|
||||
FlagSpecs: flagSpecs(tt.flags...),
|
||||
}
|
||||
h := ParamNameHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle error: %v", err)
|
||||
}
|
||||
got := strings.Join(ctx.Args, " ")
|
||||
if got != tt.want {
|
||||
t.Errorf("Args = %q, want %q", got, tt.want)
|
||||
}
|
||||
if len(ctx.Corrections) != tt.corrections {
|
||||
t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections)
|
||||
}
|
||||
for _, c := range ctx.Corrections {
|
||||
if c.Kind != "fuzzy" {
|
||||
t.Errorf("correction kind = %q, want %q", c.Kind, "fuzzy")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamNameHandlerNameAndPhase(t *testing.T) {
|
||||
h := ParamNameHandler{}
|
||||
if h.Name() != "paramname" {
|
||||
t.Errorf("Name() = %q, want %q", h.Name(), "paramname")
|
||||
}
|
||||
if h.Phase() != pipeline.PreParse {
|
||||
t.Errorf("Phase() = %v, want PreParse", h.Phase())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// ParamValueHandler normalises parameter values after Cobra parsing.
|
||||
// It operates on the structured Params map using the tool's input
|
||||
// schema to determine value types and apply format-specific
|
||||
// corrections.
|
||||
//
|
||||
// Supported normalisations:
|
||||
// - Boolean: "yes"/"no"/"1"/"0"/"on"/"off"/"True"/"FALSE" → true/false
|
||||
// - Number: "1,000" / "1_000" → 1000 (strip grouping separators)
|
||||
// - Date: multiple input formats → RFC 3339 / ISO 8601
|
||||
// - Enum: case-insensitive matching against allowed values
|
||||
type ParamValueHandler struct{}
|
||||
|
||||
func (ParamValueHandler) Name() string { return "paramvalue" }
|
||||
func (ParamValueHandler) Phase() pipeline.Phase { return pipeline.PostParse }
|
||||
|
||||
func (ParamValueHandler) Handle(ctx *pipeline.Context) error {
|
||||
if len(ctx.Params) == 0 || len(ctx.Schema) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
properties, ok := schemaProperties(ctx.Schema)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for key, value := range ctx.Params {
|
||||
propSchema, ok := properties[key].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
corrected, changed := normaliseValue(value, propSchema)
|
||||
if changed {
|
||||
original := fmt.Sprintf("%v", value)
|
||||
ctx.Params[key] = corrected
|
||||
ctx.AddCorrection("paramvalue", pipeline.PostParse, key, original, fmt.Sprintf("%v", corrected), classifyCorrection(propSchema))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normaliseValue dispatches to the appropriate normaliser based on
|
||||
// the schema type and format.
|
||||
func normaliseValue(value any, schema map[string]any) (any, bool) {
|
||||
// Enum normalisation takes priority — it is type-independent.
|
||||
if enumValues, ok := schemaEnum(schema); ok {
|
||||
return normaliseEnum(value, enumValues)
|
||||
}
|
||||
|
||||
schemaType, _ := schema["type"].(string)
|
||||
schemaFormat, _ := schema["format"].(string)
|
||||
|
||||
switch schemaType {
|
||||
case "boolean":
|
||||
return normaliseBoolean(value)
|
||||
case "integer":
|
||||
return normaliseInteger(value)
|
||||
case "number":
|
||||
return normaliseNumber(value)
|
||||
case "string":
|
||||
if isDateFormat(schemaFormat) {
|
||||
return normaliseDate(value, schemaFormat)
|
||||
}
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
// --- boolean normalisation ---
|
||||
|
||||
var booleanTrueValues = map[string]bool{
|
||||
"true": true, "yes": true, "on": true, "1": true,
|
||||
}
|
||||
var booleanFalseValues = map[string]bool{
|
||||
"false": true, "no": true, "off": true, "0": true,
|
||||
}
|
||||
|
||||
func normaliseBoolean(value any) (any, bool) {
|
||||
// Already a bool — no change needed.
|
||||
if _, ok := value.(bool); ok {
|
||||
return value, false
|
||||
}
|
||||
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return value, false
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(str))
|
||||
if booleanTrueValues[lower] {
|
||||
return true, true
|
||||
}
|
||||
if booleanFalseValues[lower] {
|
||||
return false, true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
// --- integer normalisation ---
|
||||
|
||||
func normaliseInteger(value any) (any, bool) {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return value, false
|
||||
}
|
||||
cleaned := stripNumberGrouping(str)
|
||||
if cleaned == str {
|
||||
return value, false
|
||||
}
|
||||
parsed, err := strconv.ParseInt(cleaned, 10, 64)
|
||||
if err != nil {
|
||||
return value, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// --- number normalisation ---
|
||||
|
||||
func normaliseNumber(value any) (any, bool) {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return value, false
|
||||
}
|
||||
cleaned := stripNumberGrouping(str)
|
||||
if cleaned == str {
|
||||
return value, false
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(cleaned, 64)
|
||||
if err != nil {
|
||||
return value, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// stripNumberGrouping removes comma and underscore grouping
|
||||
// separators from a numeric string.
|
||||
func stripNumberGrouping(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
s = strings.ReplaceAll(s, "_", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// --- date normalisation ---
|
||||
|
||||
// Supported input date formats, tried in order.
|
||||
var dateFormats = []string{
|
||||
time.RFC3339, // 2006-01-02T15:04:05Z07:00
|
||||
"2006-01-02T15:04:05", // ISO without timezone
|
||||
"2006-01-02 15:04:05", // space-separated datetime
|
||||
"2006-01-02", // date only
|
||||
"2006/01/02", // slash-separated
|
||||
"02-01-2006", // DD-MM-YYYY
|
||||
"01/02/2006", // US format MM/DD/YYYY
|
||||
"Jan 2, 2006", // English short month
|
||||
"January 2, 2006", // English full month
|
||||
"2 Jan 2006", // Day-first English
|
||||
"20060102", // Compact YYYYMMDD
|
||||
"20060102T150405", // Compact with time
|
||||
time.RFC1123, // Mon, 02 Jan 2006 15:04:05 MST
|
||||
time.RFC1123Z, // Mon, 02 Jan 2006 15:04:05 -0700
|
||||
}
|
||||
|
||||
func isDateFormat(format string) bool {
|
||||
switch format {
|
||||
case "date", "date-time", "datetime":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normaliseDate(value any, schemaFormat string) (any, bool) {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return value, false
|
||||
}
|
||||
str = strings.TrimSpace(str)
|
||||
if str == "" {
|
||||
return value, false
|
||||
}
|
||||
|
||||
// Try parsing as millisecond timestamp.
|
||||
if ms, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 {
|
||||
t := time.UnixMilli(ms).UTC()
|
||||
formatted := formatDate(t, schemaFormat)
|
||||
if formatted != str {
|
||||
return formatted, true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
// Try parsing as second timestamp.
|
||||
if sec, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 {
|
||||
t := time.Unix(sec, 0).UTC()
|
||||
formatted := formatDate(t, schemaFormat)
|
||||
if formatted != str {
|
||||
return formatted, true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
// Try known date formats.
|
||||
for _, layout := range dateFormats {
|
||||
t, err := time.Parse(layout, str)
|
||||
if err == nil {
|
||||
formatted := formatDate(t, schemaFormat)
|
||||
if formatted != str {
|
||||
return formatted, true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
}
|
||||
|
||||
return value, false
|
||||
}
|
||||
|
||||
// formatDate produces the output format based on the schema format
|
||||
// field. "date" produces YYYY-MM-DD, everything else produces
|
||||
// RFC 3339.
|
||||
func formatDate(t time.Time, schemaFormat string) string {
|
||||
if schemaFormat == "date" {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// --- enum normalisation ---
|
||||
|
||||
func schemaEnum(schema map[string]any) ([]any, bool) {
|
||||
raw, ok := schema["enum"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// normaliseEnum performs case-insensitive matching of a value against
|
||||
// the allowed enum values. If exactly one enum entry matches
|
||||
// (case-insensitive), the value is rewritten to match the canonical
|
||||
// case from the schema.
|
||||
func normaliseEnum(value any, allowed []any) (any, bool) {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return value, false
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(str))
|
||||
|
||||
var match any
|
||||
matches := 0
|
||||
for _, candidate := range allowed {
|
||||
candidateStr, ok := candidate.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(candidateStr) == lower {
|
||||
match = candidate
|
||||
matches++
|
||||
}
|
||||
}
|
||||
|
||||
if matches == 1 && match != value {
|
||||
return match, true
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func schemaProperties(schema map[string]any) (map[string]any, bool) {
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
return properties, ok
|
||||
}
|
||||
|
||||
func classifyCorrection(schema map[string]any) string {
|
||||
if _, ok := schemaEnum(schema); ok {
|
||||
return "enum"
|
||||
}
|
||||
schemaType, _ := schema["type"].(string)
|
||||
schemaFormat, _ := schema["format"].(string)
|
||||
if schemaType == "boolean" {
|
||||
return "boolean"
|
||||
}
|
||||
if schemaType == "integer" || schemaType == "number" {
|
||||
return "number"
|
||||
}
|
||||
if isDateFormat(schemaFormat) {
|
||||
return "date"
|
||||
}
|
||||
return "value"
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func makeSchema(properties map[string]any) map[string]any {
|
||||
return map[string]any{"properties": properties}
|
||||
}
|
||||
|
||||
func TestParamValueHandlerBoolean(t *testing.T) {
|
||||
tests := []struct {
|
||||
input any
|
||||
want any
|
||||
}{
|
||||
{"yes", true},
|
||||
{"Yes", true},
|
||||
{"YES", true},
|
||||
{"no", false},
|
||||
{"No", false},
|
||||
{"on", true},
|
||||
{"off", false},
|
||||
{"1", true},
|
||||
{"0", false},
|
||||
{"True", true},
|
||||
{"FALSE", false},
|
||||
{"true", true}, // already correct string form
|
||||
{"false", false}, // already correct string form
|
||||
{true, true}, // already bool — no change
|
||||
{false, false}, // already bool — no change
|
||||
{"maybe", "maybe"}, // not a boolean — unchanged
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Params: map[string]any{"flag": tt.input},
|
||||
Schema: makeSchema(map[string]any{
|
||||
"flag": map[string]any{"type": "boolean"},
|
||||
}),
|
||||
}
|
||||
h := ParamValueHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle error: %v", err)
|
||||
}
|
||||
got := ctx.Params["flag"]
|
||||
if got != tt.want {
|
||||
t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamValueHandlerInteger(t *testing.T) {
|
||||
tests := []struct {
|
||||
input any
|
||||
want any
|
||||
}{
|
||||
{"1,000", int64(1000)},
|
||||
{"1_000", int64(1000)},
|
||||
{"1,000,000", int64(1000000)},
|
||||
{"42", "42"}, // no grouping — unchanged
|
||||
{42, 42}, // already int — unchanged
|
||||
{"abc", "abc"}, // not a number — unchanged
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Params: map[string]any{"count": tt.input},
|
||||
Schema: makeSchema(map[string]any{
|
||||
"count": map[string]any{"type": "integer"},
|
||||
}),
|
||||
}
|
||||
h := ParamValueHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle error: %v", err)
|
||||
}
|
||||
got := ctx.Params["count"]
|
||||
if got != tt.want {
|
||||
t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamValueHandlerNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
input any
|
||||
want any
|
||||
}{
|
||||
{"1,234.56", 1234.56},
|
||||
{"1_000.5", 1000.5},
|
||||
{"3.14", "3.14"}, // no grouping — unchanged
|
||||
{3.14, 3.14}, // already float — unchanged
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Params: map[string]any{"amount": tt.input},
|
||||
Schema: makeSchema(map[string]any{
|
||||
"amount": map[string]any{"type": "number"},
|
||||
}),
|
||||
}
|
||||
h := ParamValueHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle error: %v", err)
|
||||
}
|
||||
got := ctx.Params["amount"]
|
||||
if got != tt.want {
|
||||
t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamValueHandlerDate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
format string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "slash date → ISO",
|
||||
input: "2024/03/29",
|
||||
format: "date",
|
||||
want: "2024-03-29",
|
||||
},
|
||||
{
|
||||
name: "already ISO date — no change",
|
||||
input: "2024-03-29",
|
||||
format: "date",
|
||||
want: "2024-03-29",
|
||||
},
|
||||
{
|
||||
name: "space datetime → RFC3339",
|
||||
input: "2024-03-29 14:30:00",
|
||||
format: "date-time",
|
||||
want: "2024-03-29T14:30:00Z",
|
||||
},
|
||||
{
|
||||
name: "compact YYYYMMDD → ISO date",
|
||||
input: "20240329",
|
||||
format: "date",
|
||||
want: "2024-03-29",
|
||||
},
|
||||
{
|
||||
name: "English short month → RFC3339",
|
||||
input: "Mar 29, 2024",
|
||||
format: "date-time",
|
||||
want: "2024-03-29T00:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "English full month → date",
|
||||
input: "January 15, 2024",
|
||||
format: "date",
|
||||
want: "2024-01-15",
|
||||
},
|
||||
{
|
||||
name: "millisecond timestamp → RFC3339",
|
||||
input: "1711699200000",
|
||||
format: "date-time",
|
||||
want: "2024-03-29T08:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "already RFC3339 — no change",
|
||||
input: "2024-03-29T14:30:00Z",
|
||||
format: "date-time",
|
||||
want: "2024-03-29T14:30:00Z",
|
||||
},
|
||||
{
|
||||
name: "ISO without timezone → RFC3339",
|
||||
input: "2024-03-29T14:30:00",
|
||||
format: "date-time",
|
||||
want: "2024-03-29T14:30:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Params: map[string]any{"date": tt.input},
|
||||
Schema: makeSchema(map[string]any{
|
||||
"date": map[string]any{"type": "string", "format": tt.format},
|
||||
}),
|
||||
}
|
||||
h := ParamValueHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle error: %v", err)
|
||||
}
|
||||
got, ok := ctx.Params["date"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("result not a string: %v (%T)", ctx.Params["date"], ctx.Params["date"])
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamValueHandlerEnum(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
enum []any
|
||||
want any
|
||||
}{
|
||||
{
|
||||
name: "uppercase → canonical",
|
||||
input: "ACTIVE",
|
||||
enum: []any{"active", "inactive"},
|
||||
want: "active",
|
||||
},
|
||||
{
|
||||
name: "mixed case → canonical",
|
||||
input: "Active",
|
||||
enum: []any{"active", "inactive"},
|
||||
want: "active",
|
||||
},
|
||||
{
|
||||
name: "already correct — no change",
|
||||
input: "active",
|
||||
enum: []any{"active", "inactive"},
|
||||
want: "active",
|
||||
},
|
||||
{
|
||||
name: "no match — unchanged",
|
||||
input: "deleted",
|
||||
enum: []any{"active", "inactive"},
|
||||
want: "deleted",
|
||||
},
|
||||
{
|
||||
name: "non-string value — unchanged",
|
||||
input: 42,
|
||||
enum: []any{"active"},
|
||||
want: 42,
|
||||
},
|
||||
{
|
||||
name: "canonical preserves original case",
|
||||
input: "json",
|
||||
enum: []any{"JSON", "XML", "CSV"},
|
||||
want: "JSON",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Params: map[string]any{"status": tt.input},
|
||||
Schema: makeSchema(map[string]any{
|
||||
"status": map[string]any{"type": "string", "enum": tt.enum},
|
||||
}),
|
||||
}
|
||||
h := ParamValueHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle error: %v", err)
|
||||
}
|
||||
if got := ctx.Params["status"]; got != tt.want {
|
||||
t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamValueHandlerMultipleParams(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Params: map[string]any{
|
||||
"verbose": "yes",
|
||||
"count": "1,000",
|
||||
"status": "ACTIVE",
|
||||
"created_at": "2024/03/29",
|
||||
},
|
||||
Schema: makeSchema(map[string]any{
|
||||
"verbose": map[string]any{"type": "boolean"},
|
||||
"count": map[string]any{"type": "integer"},
|
||||
"status": map[string]any{"type": "string", "enum": []any{"active", "inactive"}},
|
||||
"created_at": map[string]any{"type": "string", "format": "date"},
|
||||
}),
|
||||
}
|
||||
|
||||
h := ParamValueHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle error: %v", err)
|
||||
}
|
||||
|
||||
if got := ctx.Params["verbose"]; got != true {
|
||||
t.Errorf("verbose = %v, want true", got)
|
||||
}
|
||||
if got := ctx.Params["count"]; got != int64(1000) {
|
||||
t.Errorf("count = %v, want 1000", got)
|
||||
}
|
||||
if got := ctx.Params["status"]; got != "active" {
|
||||
t.Errorf("status = %q, want %q", got, "active")
|
||||
}
|
||||
if got := ctx.Params["created_at"]; got != "2024-03-29" {
|
||||
t.Errorf("created_at = %q, want %q", got, "2024-03-29")
|
||||
}
|
||||
if len(ctx.Corrections) != 4 {
|
||||
t.Errorf("corrections = %d, want 4", len(ctx.Corrections))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamValueHandlerEmptyInputs(t *testing.T) {
|
||||
h := ParamValueHandler{}
|
||||
|
||||
// nil params
|
||||
ctx := &pipeline.Context{Params: nil, Schema: makeSchema(map[string]any{})}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Errorf("nil params: %v", err)
|
||||
}
|
||||
|
||||
// nil schema
|
||||
ctx = &pipeline.Context{Params: map[string]any{"a": "b"}, Schema: nil}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Errorf("nil schema: %v", err)
|
||||
}
|
||||
|
||||
// schema without properties
|
||||
ctx = &pipeline.Context{Params: map[string]any{"a": "b"}, Schema: map[string]any{}}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Errorf("no properties: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamValueHandlerNameAndPhase(t *testing.T) {
|
||||
h := ParamValueHandler{}
|
||||
if h.Name() != "paramvalue" {
|
||||
t.Errorf("Name() = %q, want %q", h.Name(), "paramvalue")
|
||||
}
|
||||
if h.Phase() != pipeline.PostParse {
|
||||
t.Errorf("Phase() = %v, want PostParse", h.Phase())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// StickyHandler detects glued flag-value pairs in raw argv and splits
|
||||
// them into separate tokens. For example, "--limit100" becomes
|
||||
// "--limit", "100" when "limit" is a known flag name.
|
||||
//
|
||||
// The handler only operates on tokens that start with "--" and do not
|
||||
// contain "=". It tries to match the longest known flag name prefix
|
||||
// and, if the remaining suffix is non-empty, splits the token.
|
||||
type StickyHandler struct{}
|
||||
|
||||
func (StickyHandler) Name() string { return "sticky" }
|
||||
func (StickyHandler) Phase() pipeline.Phase { return pipeline.PreParse }
|
||||
|
||||
func (StickyHandler) Handle(ctx *pipeline.Context) error {
|
||||
if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
known := buildFlagSet(ctx.FlagSpecs)
|
||||
result := make([]string, 0, len(ctx.Args))
|
||||
|
||||
for _, arg := range ctx.Args {
|
||||
split, ok := trySplitSticky(arg, known)
|
||||
if ok {
|
||||
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky")
|
||||
result = append(result, split.flag, split.value)
|
||||
} else {
|
||||
result = append(result, arg)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Args = result
|
||||
return nil
|
||||
}
|
||||
|
||||
type stickyPair struct {
|
||||
flag string
|
||||
value string
|
||||
}
|
||||
|
||||
// trySplitSticky checks if arg looks like a glued flag-value (e.g.
|
||||
// "--limit100") and returns the split result. It requires:
|
||||
// - arg starts with "--"
|
||||
// - arg does not contain "=" (that is a valid Cobra syntax)
|
||||
// - the prefix matches a known flag name (directly or after
|
||||
// kebab-case normalisation)
|
||||
// - the remaining suffix is non-empty
|
||||
//
|
||||
// When multiple flag names match as prefixes, the longest one wins.
|
||||
// The handler also tries kebab-case normalisation of each prefix so
|
||||
// that camelCase+glued values like "--pageSize50" are correctly
|
||||
// split to "--page-size", "50".
|
||||
func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
|
||||
if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
// Strip "--" prefix to work with the bare token.
|
||||
bare := arg[2:]
|
||||
if bare == "" {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
// If the whole token is a known flag, it is not sticky — it is
|
||||
// a normal flag expecting a separate value token.
|
||||
if known[bare] || known[toKebabCase(bare)] {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
// Try longest-prefix match: walk from len-1 down to 1, looking
|
||||
// for the longest known flag that is a prefix of bare. For each
|
||||
// candidate prefix, try both the raw form and kebab-case form.
|
||||
bestLen := 0
|
||||
bestFlag := ""
|
||||
for i := len(bare) - 1; i >= 1; i-- {
|
||||
prefix := bare[:i]
|
||||
|
||||
matchedFlag := ""
|
||||
if known[prefix] {
|
||||
matchedFlag = prefix
|
||||
} else {
|
||||
kebab := toKebabCase(prefix)
|
||||
if kebab != "" && known[kebab] {
|
||||
matchedFlag = kebab
|
||||
}
|
||||
}
|
||||
|
||||
if matchedFlag != "" && i > bestLen {
|
||||
bestLen = i
|
||||
bestFlag = matchedFlag
|
||||
break // longest first since we walk from the end
|
||||
}
|
||||
}
|
||||
if bestFlag == "" {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
suffix := bare[bestLen:]
|
||||
if suffix == "" {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
return stickyPair{
|
||||
flag: "--" + bestFlag,
|
||||
value: suffix,
|
||||
}, true
|
||||
}
|
||||
|
||||
// buildFlagSet creates a set of known flag names (without "--" prefix)
|
||||
// from the context's FlagSpecs.
|
||||
func buildFlagSet(specs []pipeline.FlagInfo) map[string]bool {
|
||||
m := make(map[string]bool, len(specs))
|
||||
for _, spec := range specs {
|
||||
if spec.Name != "" {
|
||||
m[spec.Name] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func flagSpecs(names ...string) []pipeline.FlagInfo {
|
||||
specs := make([]pipeline.FlagInfo, len(names))
|
||||
for i, name := range names {
|
||||
specs[i] = pipeline.FlagInfo{Name: name, Type: "string"}
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
func TestStickyHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
flags []string
|
||||
want string
|
||||
corrections int
|
||||
}{
|
||||
{
|
||||
name: "basic split --limit100",
|
||||
args: []string{"--limit100"},
|
||||
flags: []string{"limit"},
|
||||
want: "--limit 100",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "no split when flag takes value separately",
|
||||
args: []string{"--limit", "100"},
|
||||
flags: []string{"limit"},
|
||||
want: "--limit 100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "no split when = syntax used",
|
||||
args: []string{"--limit=100"},
|
||||
flags: []string{"limit"},
|
||||
want: "--limit=100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "no split when flag name is not known",
|
||||
args: []string{"--unknown100"},
|
||||
flags: []string{"limit"},
|
||||
want: "--unknown100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "split with string value",
|
||||
args: []string{"--nameJohn"},
|
||||
flags: []string{"name"},
|
||||
want: "--name John",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "longest prefix wins",
|
||||
args: []string{"--user-id123"},
|
||||
flags: []string{"user", "user-id"},
|
||||
want: "--user-id 123",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple sticky args in one invocation",
|
||||
args: []string{"--limit100", "--offset50"},
|
||||
flags: []string{"limit", "offset"},
|
||||
want: "--limit 100 --offset 50",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "mixed sticky and normal args",
|
||||
args: []string{"--limit100", "--name", "test", "--offset50"},
|
||||
flags: []string{"limit", "name", "offset"},
|
||||
want: "--limit 100 --name test --offset 50",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "single dash prefix is ignored",
|
||||
args: []string{"-l100"},
|
||||
flags: []string{"l"},
|
||||
want: "-l100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "empty args",
|
||||
args: []string{},
|
||||
flags: []string{"limit"},
|
||||
want: "",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "bare double dash",
|
||||
args: []string{"--"},
|
||||
flags: []string{"limit"},
|
||||
want: "--",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "no flag specs available",
|
||||
args: []string{"--limit100"},
|
||||
flags: []string{},
|
||||
want: "--limit100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "exact flag name is not split",
|
||||
args: []string{"--limit"},
|
||||
flags: []string{"limit"},
|
||||
want: "--limit",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "boolean-like value still splits",
|
||||
args: []string{"--verbosetrue"},
|
||||
flags: []string{"verbose"},
|
||||
want: "--verbose true",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "hyphenated flag name with numeric suffix",
|
||||
args: []string{"--page-size50"},
|
||||
flags: []string{"page-size", "page"},
|
||||
want: "--page-size 50",
|
||||
corrections: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string{}, tt.args...),
|
||||
FlagSpecs: flagSpecs(tt.flags...),
|
||||
}
|
||||
h := StickyHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle returned error: %v", err)
|
||||
}
|
||||
got := strings.Join(ctx.Args, " ")
|
||||
if got != tt.want {
|
||||
t.Errorf("Args = %q, want %q", got, tt.want)
|
||||
}
|
||||
if len(ctx.Corrections) != tt.corrections {
|
||||
t.Errorf("Corrections count = %d, want %d", len(ctx.Corrections), tt.corrections)
|
||||
}
|
||||
for _, c := range ctx.Corrections {
|
||||
if c.Kind != "sticky" {
|
||||
t.Errorf("correction kind = %q, want %q", c.Kind, "sticky")
|
||||
}
|
||||
if c.Handler != "sticky" {
|
||||
t.Errorf("correction handler = %q, want %q", c.Handler, "sticky")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStickyHandlerNameAndPhase(t *testing.T) {
|
||||
h := StickyHandler{}
|
||||
if h.Name() != "sticky" {
|
||||
t.Errorf("Name() = %q, want %q", h.Name(), "sticky")
|
||||
}
|
||||
if h.Phase() != pipeline.PreParse {
|
||||
t.Errorf("Phase() = %v, want PreParse", h.Phase())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user