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:
audanye-sudo committed 2026-03-29 16:28:19 +08:00
1 parent 4a4633d188
commit f75227aad1
14 files changed
+2665

No files matched your search

+146
View File
@@ -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(), "-")
}
+186
View File
@@ -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)
}
}
}
+164
View File
@@ -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())
}
}
+312
View File
@@ -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())
}
}
+139
View File
@@ -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
}
+183
View File
@@ -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())
}
}